worklog-opsdevnz 0.1.3__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {worklog_opsdevnz-0.1.3/src/worklog_opsdevnz.egg-info → worklog_opsdevnz-0.2.1}/PKG-INFO +6 -1
  2. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/README.md +4 -0
  3. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/pyproject.toml +7 -1
  4. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz/cli.py +19 -3
  5. worklog_opsdevnz-0.2.1/src/worklog_opsdevnz/paths.py +73 -0
  6. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz/template.py +37 -10
  7. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1/src/worklog_opsdevnz.egg-info}/PKG-INFO +6 -1
  8. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz.egg-info/requires.txt +1 -0
  9. worklog_opsdevnz-0.2.1/tests/test_cli.py +137 -0
  10. worklog_opsdevnz-0.2.1/tests/test_paths.py +79 -0
  11. worklog_opsdevnz-0.2.1/tests/test_template.py +285 -0
  12. worklog_opsdevnz-0.1.3/src/worklog_opsdevnz/paths.py +0 -28
  13. worklog_opsdevnz-0.1.3/tests/test_cli.py +0 -71
  14. worklog_opsdevnz-0.1.3/tests/test_paths.py +0 -29
  15. worklog_opsdevnz-0.1.3/tests/test_template.py +0 -99
  16. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/LICENSE +0 -0
  17. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/setup.cfg +0 -0
  18. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz/__init__.py +0 -0
  19. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz/config.py +0 -0
  20. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz.egg-info/SOURCES.txt +0 -0
  21. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz.egg-info/dependency_links.txt +0 -0
  22. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz.egg-info/entry_points.txt +0 -0
  23. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/src/worklog_opsdevnz.egg-info/top_level.txt +0 -0
  24. {worklog_opsdevnz-0.1.3 → worklog_opsdevnz-0.2.1}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: worklog-opsdevnz
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: Configurable worklog management CLI for dated development logs
5
5
  Author-email: "OpsDev.nz Collective" <john@opsdev.nz>
6
6
  License: Apache-2.0
@@ -29,6 +29,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
29
  Requires-Dist: pytest-mock>=3.10; extra == "dev"
30
30
  Requires-Dist: ruff>=0.4; extra == "dev"
31
31
  Requires-Dist: mypy>=1.0; extra == "dev"
32
+ Requires-Dist: zensical>=0.0.44; extra == "dev"
32
33
  Dynamic: license-file
33
34
 
34
35
  # worklog-opsdevnz
@@ -49,6 +50,9 @@ worklog-opsdevnz --version
49
50
 
50
51
  # Override the editor for this run
51
52
  worklog-opsdevnz --editor nvim
53
+
54
+ # Open the most recent previous entry
55
+ worklog-opsdevnz -p
52
56
  ```
53
57
 
54
58
  ## Configuration
@@ -84,6 +88,7 @@ title = "Next"
84
88
  - Configurable section headers per project
85
89
  - Custom body templates with `{{DATE}}` and `{{TITLE}}` placeholders
86
90
  - Editor integration: `-e` flag → config → `$VISUAL` → `$EDITOR`
91
+ - `-p` / `--previous` flag to open the most recent past entry
87
92
  - `--version` flag for installed version
88
93
 
89
94
  ## Requirements
@@ -16,6 +16,9 @@ worklog-opsdevnz --version
16
16
 
17
17
  # Override the editor for this run
18
18
  worklog-opsdevnz --editor nvim
19
+
20
+ # Open the most recent previous entry
21
+ worklog-opsdevnz -p
19
22
  ```
20
23
 
21
24
  ## Configuration
@@ -51,6 +54,7 @@ title = "Next"
51
54
  - Configurable section headers per project
52
55
  - Custom body templates with `{{DATE}}` and `{{TITLE}}` placeholders
53
56
  - Editor integration: `-e` flag → config → `$VISUAL` → `$EDITOR`
57
+ - `-p` / `--previous` flag to open the most recent past entry
54
58
  - `--version` flag for installed version
55
59
 
56
60
  ## Requirements
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "worklog-opsdevnz"
7
- version = "0.1.3"
7
+ version = "0.2.1"
8
8
  description = "Configurable worklog management CLI for dated development logs"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -37,6 +37,7 @@ dev = [
37
37
  "pytest-mock>=3.10",
38
38
  "ruff>=0.4",
39
39
  "mypy>=1.0",
40
+ "zensical>=0.0.44",
40
41
  ]
41
42
 
42
43
  [project.urls]
@@ -64,6 +65,11 @@ addopts = "-v --cov=src/worklog_opsdevnz --cov-report=term-missing"
64
65
  line-length = 88
65
66
  target-version = "py312"
66
67
 
68
+ # Explicit rule set (ruff's historical defaults) so CI is stable across
69
+ # ruff upgrades — DTZ/I/etc. are intentionally not enabled: local-date
70
+ # handling is this tool's core domain, not an oversight.
71
+ lint.select = ["E4", "E7", "E9", "F"]
72
+
67
73
  [tool.mypy]
68
74
  python_version = "3.12"
69
75
  warn_return_any = true
@@ -9,7 +9,7 @@ import click
9
9
 
10
10
  from worklog_opsdevnz import __version__
11
11
  from worklog_opsdevnz.config import get_config
12
- from worklog_opsdevnz.paths import resolve_path
12
+ from worklog_opsdevnz.paths import find_previous, resolve_path
13
13
  from worklog_opsdevnz.template import generate_content
14
14
 
15
15
 
@@ -21,11 +21,27 @@ from worklog_opsdevnz.template import generate_content
21
21
  default=None,
22
22
  help="Override the editor command (overrides config, $VISUAL, $EDITOR).",
23
23
  )
24
- def main(editor: str | None) -> None:
25
- """Create or open today's worklog entry."""
24
+ @click.option(
25
+ "-p",
26
+ "--previous",
27
+ is_flag=True,
28
+ help="Open the most recent worklog entry before today (never creates).",
29
+ )
30
+ def main(editor: str | None, previous: bool) -> None:
31
+ """Create or open today's worklog entry, or the previous one with -p."""
26
32
  entry_date = date.today()
27
33
 
28
34
  config = get_config()
35
+
36
+ if previous:
37
+ target = find_previous(config, entry_date)
38
+ if target is None:
39
+ click.echo("No previous worklog entry found.", err=True)
40
+ return
41
+ print(f"Opening previous worklog: {target}")
42
+ _open_editor(target, editor, config.get("editor"))
43
+ return
44
+
29
45
  target = resolve_path(config, entry_date)
30
46
  target.parent.mkdir(parents=True, exist_ok=True)
31
47
 
@@ -0,0 +1,73 @@
1
+ """Path resolution for worklog files."""
2
+
3
+ from datetime import date, datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def resolve_path(
9
+ config: dict[str, Any],
10
+ entry_date: date,
11
+ ) -> Path:
12
+ """Compute the target file path based on config structure."""
13
+ base = Path(config.get("worklog_dir", "docs/worklog"))
14
+ structure = config.get("structure", "year")
15
+ suffix = config.get("suffix", "worklog")
16
+
17
+ if structure == "flat":
18
+ filename = f"{entry_date.isoformat()}-{suffix}.md"
19
+ return base / filename
20
+ elif structure == "year-month":
21
+ year = entry_date.strftime("%Y")
22
+ month = entry_date.strftime("%m")
23
+ filename = f"{entry_date.strftime('%d-%m-%Y')}-{suffix}.md"
24
+ return base / year / month / filename
25
+ else: # "year" (default)
26
+ year = entry_date.strftime("%Y")
27
+ filename = f"{entry_date.strftime('%d-%m-%Y')}-{suffix}.md"
28
+ return base / year / filename
29
+
30
+
31
+ _DATE_FORMATS = ("%Y-%m-%d", "%d-%m-%Y")
32
+
33
+
34
+ def _parse_entry_date(filename: str, suffix: str) -> date | None:
35
+ """Extract the entry date from a worklog filename, or None if not parseable."""
36
+ stem = filename.removesuffix(".md")
37
+ prefix = stem.removesuffix(f"-{suffix}")
38
+ for fmt in _DATE_FORMATS:
39
+ try:
40
+ return datetime.strptime(prefix, fmt).date()
41
+ except ValueError:
42
+ continue
43
+ return None
44
+
45
+
46
+ def find_previous(config: dict[str, Any], today: date) -> Path | None:
47
+ """Locate the most recent worklog entry strictly before *today*.
48
+
49
+ Scans the directory tree implied by the configured structure mode
50
+ (flat, year, or year-month) for files matching ``*-{suffix}.md``,
51
+ parses their dates, and returns the newest one older than today.
52
+ Returns None when no previous entry exists. Never creates anything.
53
+ """
54
+ base = Path(config.get("worklog_dir", "docs/worklog"))
55
+ suffix = config.get("suffix", "worklog")
56
+ structure = config.get("structure", "year")
57
+
58
+ pattern = f"*-{suffix}.md"
59
+ if structure == "flat":
60
+ candidates = base.glob(pattern)
61
+ elif structure == "year-month":
62
+ candidates = base.glob(f"*/*/{pattern}")
63
+ else: # "year" (default)
64
+ candidates = base.glob(f"*/{pattern}")
65
+
66
+ best: tuple[date, Path] | None = None
67
+ for path in candidates:
68
+ entry_date = _parse_entry_date(path.name, suffix)
69
+ if entry_date is None or entry_date >= today:
70
+ continue
71
+ if best is None or entry_date > best[0]:
72
+ best = (entry_date, path)
73
+ return best[1] if best else None
@@ -36,13 +36,39 @@ def generate_body(config: dict[str, Any]) -> str:
36
36
  return "\n".join(lines)
37
37
 
38
38
 
39
- def render_template(template_path: str, iso_date: str) -> str:
40
- """Render a custom Markdown template with placeholder substitution."""
39
+ def _render_tags(tags: list[str]) -> str:
40
+ """Render tags as a block-style YAML list substitution.
41
+
42
+ Empty list → '[]'. Populated list → leading newline followed by
43
+ each tag on its own indented line with '-' prefix.
44
+ """
45
+ if not tags:
46
+ return "[]"
47
+ return "\n" + "\n".join(f" - {t}" for t in tags)
48
+
49
+
50
+ def render_template(
51
+ template_path: str,
52
+ iso_date: str,
53
+ config: dict[str, Any],
54
+ ) -> str:
55
+ """Render a custom Markdown template with placeholder substitution.
56
+
57
+ Supports {{DATE}}, {{TITLE}}, {{AUTHOR}}, and {{TAGS}} placeholders.
58
+ All placeholders are case-sensitive.
59
+ """
41
60
  title = f"Work Log - {iso_date}"
61
+ tags = _render_tags(config.get("default_tags", []))
62
+ author = config.get("author", "unknown")
42
63
  with open(template_path) as f:
43
64
  content = f.read()
44
65
  content = content.replace("{{DATE}}", iso_date)
45
66
  content = content.replace("{{TITLE}}", title)
67
+ content = content.replace("{{AUTHOR}}", author)
68
+ content = content.replace("{{TAGS}}", tags)
69
+ # Clean trailing whitespace (avoids artifacts when placeholder
70
+ # substitution leaves space before a newline, e.g. 'tags: \n')
71
+ content = "\n".join(line.rstrip() for line in content.split("\n"))
46
72
  return content
47
73
 
48
74
 
@@ -52,16 +78,17 @@ def generate_content(
52
78
  ) -> str:
53
79
  """Generate full worklog content.
54
80
 
55
- If a 'template' field is set in config, renders that file as the body.
56
- Otherwise uses the built-in sections-based body.
57
- Frontmatter is always generated regardless of template.
58
- """
59
- frontmatter = generate_frontmatter(config, iso_date)
81
+ If a 'template' field is set in config, the template defines the
82
+ complete entry (including YAML frontmatter). Placeholders are
83
+ substituted but nothing else is prepended.
60
84
 
85
+ Otherwise uses the built-in default: config-driven frontmatter and
86
+ sections-based body.
87
+ """
61
88
  template_path = config.get("template")
62
89
  if template_path:
63
- body = render_template(template_path, iso_date)
64
- else:
65
- body = generate_body(config)
90
+ return render_template(template_path, iso_date, config)
66
91
 
92
+ frontmatter = generate_frontmatter(config, iso_date)
93
+ body = generate_body(config)
67
94
  return f"{frontmatter}\n{body}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: worklog-opsdevnz
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: Configurable worklog management CLI for dated development logs
5
5
  Author-email: "OpsDev.nz Collective" <john@opsdev.nz>
6
6
  License: Apache-2.0
@@ -29,6 +29,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
29
  Requires-Dist: pytest-mock>=3.10; extra == "dev"
30
30
  Requires-Dist: ruff>=0.4; extra == "dev"
31
31
  Requires-Dist: mypy>=1.0; extra == "dev"
32
+ Requires-Dist: zensical>=0.0.44; extra == "dev"
32
33
  Dynamic: license-file
33
34
 
34
35
  # worklog-opsdevnz
@@ -49,6 +50,9 @@ worklog-opsdevnz --version
49
50
 
50
51
  # Override the editor for this run
51
52
  worklog-opsdevnz --editor nvim
53
+
54
+ # Open the most recent previous entry
55
+ worklog-opsdevnz -p
52
56
  ```
53
57
 
54
58
  ## Configuration
@@ -84,6 +88,7 @@ title = "Next"
84
88
  - Configurable section headers per project
85
89
  - Custom body templates with `{{DATE}}` and `{{TITLE}}` placeholders
86
90
  - Editor integration: `-e` flag → config → `$VISUAL` → `$EDITOR`
91
+ - `-p` / `--previous` flag to open the most recent past entry
87
92
  - `--version` flag for installed version
88
93
 
89
94
  ## Requirements
@@ -7,3 +7,4 @@ pytest-cov>=4.0
7
7
  pytest-mock>=3.10
8
8
  ruff>=0.4
9
9
  mypy>=1.0
10
+ zensical>=0.0.44
@@ -0,0 +1,137 @@
1
+ """Tests for CLI entry point."""
2
+
3
+ from datetime import date, timedelta
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from worklog_opsdevnz.cli import main
8
+
9
+
10
+ def test_main_creates_file(tmp_path, monkeypatch):
11
+ """Running with no args creates today's worklog in tmp_path."""
12
+ monkeypatch.chdir(tmp_path)
13
+ runner = CliRunner()
14
+ result = runner.invoke(main, [])
15
+ assert result.exit_code == 0
16
+ assert "Created" in result.output
17
+ expected_leaf = (
18
+ f"docs/worklog/{date.today().year}/{date.today():%d-%m-%Y}-worklog.md"
19
+ )
20
+ assert expected_leaf in result.output
21
+
22
+
23
+ def test_main_opens_existing(tmp_path, monkeypatch):
24
+ """Running again opens the existing file."""
25
+ monkeypatch.chdir(tmp_path)
26
+ runner = CliRunner()
27
+ runner.invoke(main, [])
28
+ result = runner.invoke(main, [])
29
+ assert result.exit_code == 0
30
+ assert "Opening existing" in result.output
31
+
32
+
33
+ def test_main_help():
34
+ """--help works."""
35
+ runner = CliRunner()
36
+ result = runner.invoke(main, ["--help"])
37
+ assert result.exit_code == 0
38
+ assert "--editor" in result.output
39
+
40
+
41
+ def test_main_version():
42
+ """--version prints the installed version."""
43
+ runner = CliRunner()
44
+ result = runner.invoke(main, ["--version"])
45
+ assert result.exit_code == 0
46
+ assert "worklog-opsdevnz, version" in result.output
47
+ # Extract and verify it's a non-empty version string
48
+ version_str = result.output.strip().split(", version ")[-1]
49
+ assert version_str
50
+
51
+
52
+ def test_main_editor_from_config(tmp_path, monkeypatch):
53
+ """Editor in worklog.toml is resolved before $VISUAL/$EDITOR."""
54
+ monkeypatch.chdir(tmp_path)
55
+ (tmp_path / "worklog.toml").write_text('editor = "fake-editor-test"\n')
56
+ runner = CliRunner()
57
+ result = runner.invoke(main, [])
58
+ assert result.exit_code == 0
59
+ # config-based editor picked up but not found in PATH
60
+ assert "Editor 'fake-editor-test' not found" in result.output
61
+
62
+
63
+ def test_main_editor_cli_overrides_config(tmp_path, monkeypatch):
64
+ """-e flag overrides the editor set in worklog.toml."""
65
+ monkeypatch.chdir(tmp_path)
66
+ (tmp_path / "worklog.toml").write_text('editor = "fake-editor-test"\n')
67
+ runner = CliRunner()
68
+ result = runner.invoke(main, ["-e", "override-editor-test"])
69
+ assert result.exit_code == 0
70
+ # CLI override takes priority over config
71
+ assert "Editor 'override-editor-test' not found" in result.output
72
+
73
+
74
+ def test_previous_no_entries(tmp_path, monkeypatch):
75
+ """-p with no prior entries: message to stderr, exit 0, creates nothing."""
76
+ monkeypatch.chdir(tmp_path)
77
+ runner = CliRunner()
78
+ result = runner.invoke(main, ["-p"])
79
+ assert result.exit_code == 0
80
+ assert "No previous worklog entry found." in result.stderr
81
+ assert not (tmp_path / "docs").exists()
82
+
83
+
84
+ def test_previous_opens_most_recent_before_today(tmp_path, monkeypatch):
85
+ """-p opens the newest prior entry, not today's, and creates no file."""
86
+ monkeypatch.chdir(tmp_path)
87
+ today = date.today()
88
+ year_dir = tmp_path / "docs" / "worklog" / str(today.year)
89
+ year_dir.mkdir(parents=True)
90
+ older = (today - timedelta(days=3)).strftime("%d-%m-%Y")
91
+ recent = (today - timedelta(days=1)).strftime("%d-%m-%Y")
92
+ (year_dir / f"{older}-worklog.md").write_text("older")
93
+ (year_dir / f"{recent}-worklog.md").write_text("recent")
94
+ today_file = year_dir / f"{today:%d-%m-%Y}-worklog.md"
95
+ today_file.write_text("today")
96
+
97
+ runner = CliRunner()
98
+ result = runner.invoke(main, ["--previous"])
99
+ assert result.exit_code == 0
100
+ assert recent in result.output
101
+ assert today.strftime("%d-%m-%Y") not in result.output
102
+
103
+
104
+ def test_previous_does_not_create_today(tmp_path, monkeypatch):
105
+ """-p must not create today's entry even though it does not exist."""
106
+ monkeypatch.chdir(tmp_path)
107
+ today = date.today()
108
+ year_dir = tmp_path / "docs" / "worklog" / str(today.year)
109
+ year_dir.mkdir(parents=True)
110
+ (year_dir / f"{(today - timedelta(days=2)):%d-%m-%Y}-worklog.md").write_text("x")
111
+
112
+ runner = CliRunner()
113
+ runner.invoke(main, ["-p"])
114
+ assert not (year_dir / f"{today:%d-%m-%Y}-worklog.md").exists()
115
+
116
+
117
+ def test_previous_editor_override(tmp_path, monkeypatch):
118
+ """-p combined with -e applies the editor override to the previous entry."""
119
+ monkeypatch.chdir(tmp_path)
120
+ today = date.today()
121
+ year_dir = tmp_path / "docs" / "worklog" / str(today.year)
122
+ year_dir.mkdir(parents=True)
123
+ recent = (today - timedelta(days=1)).strftime("%d-%m-%Y")
124
+ (year_dir / f"{recent}-worklog.md").write_text("recent")
125
+
126
+ runner = CliRunner()
127
+ result = runner.invoke(main, ["-p", "-e", "override-editor-test"])
128
+ assert result.exit_code == 0
129
+ assert "Editor 'override-editor-test' not found" in result.output
130
+
131
+
132
+ def test_previous_help():
133
+ """--help mentions the --previous flag."""
134
+ runner = CliRunner()
135
+ result = runner.invoke(main, ["--help"])
136
+ assert result.exit_code == 0
137
+ assert "--previous" in result.output
@@ -0,0 +1,79 @@
1
+ """Tests for path resolution."""
2
+
3
+ from datetime import date
4
+
5
+ from worklog_opsdevnz.paths import find_previous, resolve_path
6
+
7
+
8
+ def test_resolve_path_flat():
9
+ config = {"worklog_dir": "docs/worklog", "structure": "flat", "suffix": "worklog"}
10
+ path = resolve_path(config, date(2026, 5, 23))
11
+ assert str(path) == "docs/worklog/2026-05-23-worklog.md"
12
+
13
+
14
+ def test_resolve_path_year():
15
+ config = {"worklog_dir": "logs", "structure": "year", "suffix": "log"}
16
+ path = resolve_path(config, date(2026, 5, 23))
17
+ assert str(path) == "logs/2026/23-05-2026-log.md"
18
+
19
+
20
+ def test_resolve_path_year_month():
21
+ config = {"worklog_dir": "logs", "structure": "year-month", "suffix": "ops"}
22
+ path = resolve_path(config, date(2026, 5, 23))
23
+ assert str(path) == "logs/2026/05/23-05-2026-ops.md"
24
+
25
+
26
+ def test_resolve_path_custom_suffix():
27
+ config = {"structure": "year", "suffix": "retro"}
28
+ path = resolve_path(config, date(2026, 1, 1))
29
+ assert str(path).endswith("-retro.md")
30
+
31
+
32
+ def test_find_previous_year(tmp_path):
33
+ base = tmp_path / "logs" / "2026"
34
+ base.mkdir(parents=True)
35
+ (base / "28-08-2026-worklog.md").write_text("old")
36
+ (base / "01-09-2026-worklog.md").write_text("prev")
37
+ (base / "03-09-2026-worklog.md").write_text("today")
38
+ config = {"worklog_dir": str(tmp_path / "logs"), "structure": "year", "suffix": "worklog"}
39
+ result = find_previous(config, date(2026, 9, 3))
40
+ assert result is not None
41
+ assert result.name == "01-09-2026-worklog.md"
42
+
43
+
44
+ def test_find_previous_year_month(tmp_path):
45
+ base = tmp_path / "docs" / "worklogs"
46
+ (base / "2026" / "08").mkdir(parents=True)
47
+ (base / "2026" / "09").mkdir(parents=True)
48
+ (base / "2026" / "08" / "27-08-2026-worklog.md").write_text("aug")
49
+ (base / "2026" / "09" / "01-09-2026-worklog.md").write_text("sep")
50
+ config = {"worklog_dir": str(base), "structure": "year-month", "suffix": "worklog"}
51
+ result = find_previous(config, date(2026, 9, 2))
52
+ assert result is not None
53
+ assert result.name == "01-09-2026-worklog.md"
54
+
55
+
56
+ def test_find_previous_flat_iso_names(tmp_path):
57
+ base = tmp_path / "wl"
58
+ base.mkdir()
59
+ (base / "2026-08-30-worklog.md").write_text("a")
60
+ (base / "2026-09-01-worklog.md").write_text("b")
61
+ config = {"worklog_dir": str(base), "structure": "flat", "suffix": "worklog"}
62
+ result = find_previous(config, date(2026, 9, 2))
63
+ assert result is not None
64
+ assert result.name == "2026-09-01-worklog.md"
65
+
66
+
67
+ def test_find_previous_ignores_today_unrelated_and_wrong_suffix(tmp_path):
68
+ base = tmp_path / "logs" / "2026"
69
+ base.mkdir(parents=True)
70
+ (base / "03-09-2026-worklog.md").write_text("today")
71
+ (base / "README.md").write_text("not an entry")
72
+ (base / "01-09-2026-journal.md").write_text("wrong suffix")
73
+ config = {"worklog_dir": str(tmp_path / "logs"), "structure": "year", "suffix": "worklog"}
74
+ assert find_previous(config, date(2026, 9, 3)) is None
75
+
76
+
77
+ def test_find_previous_missing_dir(tmp_path):
78
+ config = {"worklog_dir": str(tmp_path / "nothing"), "structure": "year", "suffix": "worklog"}
79
+ assert find_previous(config, date(2026, 9, 3)) is None
@@ -0,0 +1,285 @@
1
+ """Tests for frontmatter and body generation."""
2
+
3
+ import pytest
4
+
5
+ from worklog_opsdevnz.template import (
6
+ generate_frontmatter,
7
+ generate_body,
8
+ generate_content,
9
+ render_template,
10
+ )
11
+
12
+
13
+ def test_generate_frontmatter():
14
+ config = {
15
+ "author": "Test Author",
16
+ "default_tags": ["internal", "test"],
17
+ }
18
+ fm = generate_frontmatter(config, "2026-05-23")
19
+ assert "title: " in fm
20
+ assert "Work Log - 2026-05-23" in fm
21
+ assert "date: 2026-05-23" in fm
22
+ assert "author: Test Author" in fm
23
+ assert " - internal" in fm
24
+ assert " - test" in fm
25
+ assert "draft: false" in fm
26
+ assert fm.startswith("---")
27
+ assert fm.strip().endswith("---")
28
+
29
+
30
+ def test_generate_body_with_sections():
31
+ config = {
32
+ "sections": [
33
+ {"title": "Focus", "content": ""},
34
+ {"title": "Notes", "content": "some note"},
35
+ ]
36
+ }
37
+ body = generate_body(config)
38
+ assert "## Focus" in body
39
+ assert "## Notes" in body
40
+ assert "some note" in body
41
+
42
+
43
+ def test_generate_content_full():
44
+ config = {
45
+ "author": "opsdev",
46
+ "default_tags": ["log"],
47
+ "sections": [{"title": "Today", "content": ""}],
48
+ }
49
+ content = generate_content(config, "2026-05-23")
50
+ assert content.startswith("---")
51
+ assert "Work Log - 2026-05-23" in content
52
+ assert "## Today" in content
53
+
54
+
55
+ def test_render_template_with_placeholders(tmp_path):
56
+ template = tmp_path / "my-template.md"
57
+ template.write_text(
58
+ "---\n"
59
+ 'title: "{{TITLE}}"\n'
60
+ "date: {{DATE}}\n"
61
+ "author: {{AUTHOR}}\n"
62
+ "tags: {{TAGS}}\n"
63
+ "draft: false\n"
64
+ "---\n\n"
65
+ "# {{TITLE}}\n\n"
66
+ "Date: {{DATE}}\n\n"
67
+ "Free-form notes."
68
+ )
69
+
70
+ config = {"author": "opsdev", "default_tags": ["dev", "log"]}
71
+ result = render_template(str(template), "2026-05-26", config)
72
+
73
+ # Placeholders replaced
74
+ assert "{{DATE}}" not in result
75
+ assert "{{TITLE}}" not in result
76
+ assert "{{AUTHOR}}" not in result
77
+ assert "{{TAGS}}" not in result
78
+
79
+ # Content verified
80
+ assert "# Work Log - 2026-05-26" in result
81
+ assert "Date: 2026-05-26" in result
82
+ assert "Free-form notes." in result
83
+ assert 'title: "Work Log - 2026-05-26"' in result
84
+ assert "date: 2026-05-26" in result
85
+ assert "author: opsdev" in result
86
+ assert "draft: false" in result
87
+
88
+ # Tags rendered as block-style YAML
89
+ assert " - dev" in result
90
+ assert " - log" in result
91
+
92
+
93
+ def test_render_template_file_not_found():
94
+ with pytest.raises(FileNotFoundError):
95
+ render_template("/nonexistent/template.md", "2026-05-26", {})
96
+
97
+
98
+ def test_generate_content_with_template(tmp_path):
99
+ """When template is set, it defines the complete entry.
100
+
101
+ No auto-generated frontmatter is prepended.
102
+ """
103
+ template = tmp_path / "my-template.md"
104
+ template.write_text(
105
+ "---\n"
106
+ 'title: "{{TITLE}}"\n'
107
+ "date: {{DATE}}\n"
108
+ "author: {{AUTHOR}}\n"
109
+ "tags: {{TAGS}}\n"
110
+ "draft: false\n"
111
+ "---\n\n"
112
+ "# {{TITLE}}\n\n"
113
+ "Date: {{DATE}}"
114
+ )
115
+
116
+ config = {
117
+ "author": "opsdev",
118
+ "default_tags": ["log"],
119
+ "template": str(template),
120
+ }
121
+ content = generate_content(config, "2026-05-26")
122
+
123
+ # Template defines the full entry — its frontmatter is present
124
+ assert content.startswith("---")
125
+ assert 'title: "Work Log - 2026-05-26"' in content
126
+ assert "date: 2026-05-26" in content
127
+ assert "author: opsdev" in content
128
+ assert " - log" in content
129
+ assert "draft: false" in content
130
+
131
+ # Body comes from template
132
+ assert "# Work Log - 2026-05-26" in content
133
+ assert "Date: 2026-05-26" in content
134
+
135
+ # No duplicate frontmatter — only one '---' block
136
+ assert content.count("---") == 2 # opening + closing
137
+
138
+ # Sections are NOT present (template controls everything)
139
+ assert "## Focus for Today" not in content
140
+
141
+
142
+ def test_generate_content_without_template():
143
+ """No template field → built-in sections body, unchanged behaviour."""
144
+ config = {
145
+ "author": "opsdev",
146
+ "default_tags": ["log"],
147
+ "sections": [{"title": "Today", "content": ""}],
148
+ }
149
+ content = generate_content(config, "2026-05-26")
150
+ assert "## Today" in content
151
+
152
+
153
+ # ── {{TAGS}} rendering ────────────────────────────────────────────
154
+
155
+
156
+ def test_render_template_tags_empty(tmp_path):
157
+ """Empty default_tags → {{TAGS}} substitutes as '[]'."""
158
+ template = tmp_path / "t.md"
159
+ template.write_text("tags: {{TAGS}}\n")
160
+
161
+ result = render_template(str(template), "2026-06-01", {"default_tags": []})
162
+ assert "tags: []" in result
163
+ assert "{{TAGS}}" not in result
164
+
165
+
166
+ def test_render_template_tags_populated(tmp_path):
167
+ """Populated default_tags → {{TAGS}} renders as block-style YAML list."""
168
+ template = tmp_path / "t.md"
169
+ template.write_text("tags: {{TAGS}}\n")
170
+
171
+ config = {"default_tags": ["dev", "log", "ops"]}
172
+ result = render_template(str(template), "2026-06-01", config)
173
+
174
+ # Block-style: leading newline + indented items, no trailing whitespace
175
+ assert "tags:\n - dev\n - log\n - ops" in result
176
+ assert "{{TAGS}}" not in result
177
+
178
+
179
+ def test_render_template_tags_not_configured(tmp_path):
180
+ """No default_tags in config → {{TAGS}} substitutes as '[]'."""
181
+ template = tmp_path / "t.md"
182
+ template.write_text("tags: {{TAGS}}\n")
183
+
184
+ result = render_template(str(template), "2026-06-01", {})
185
+ assert "tags: []" in result
186
+
187
+
188
+ # ── {{AUTHOR}} rendering ──────────────────────────────────────────
189
+
190
+
191
+ def test_render_template_author_configured(tmp_path):
192
+ """{{AUTHOR}} pulls the author value from config."""
193
+ template = tmp_path / "t.md"
194
+ template.write_text("author: {{AUTHOR}}\n")
195
+
196
+ result = render_template(str(template), "2026-06-01", {"author": "opsdev"})
197
+ assert "author: opsdev" in result
198
+ assert "{{AUTHOR}}" not in result
199
+
200
+
201
+ def test_render_template_author_fallback(tmp_path):
202
+ """{{AUTHOR}} falls back to 'unknown' when not in config."""
203
+ template = tmp_path / "t.md"
204
+ template.write_text("author: {{AUTHOR}}\n")
205
+
206
+ result = render_template(str(template), "2026-06-01", {})
207
+ assert "author: unknown" in result
208
+
209
+
210
+ # ── Full-entry template edge cases ────────────────────────────────
211
+
212
+
213
+ def test_generate_content_template_defines_full_entry(tmp_path):
214
+ """Template with custom YAML fields — the tool only substitutes placeholders."""
215
+ template = tmp_path / "custom.md"
216
+ template.write_text(
217
+ "---\n"
218
+ 'title: "{{TITLE}}"\n'
219
+ "date: {{DATE}}\n"
220
+ "author: {{AUTHOR}}\n"
221
+ "tags: {{TAGS}}\n"
222
+ "mood: creative\n"
223
+ "project: outcome-engineering\n"
224
+ "draft: false\n"
225
+ "---\n\n"
226
+ "# {{TITLE}}\n\n"
227
+ "Custom body content."
228
+ )
229
+
230
+ config = {
231
+ "author": "opsdev",
232
+ "default_tags": ["dev"],
233
+ "template": str(template),
234
+ }
235
+ content = generate_content(config, "2026-06-05")
236
+
237
+ # Custom fields preserved verbatim
238
+ assert "mood: creative" in content
239
+ assert "project: outcome-engineering" in content
240
+
241
+ # Placeholders substituted
242
+ assert 'title: "Work Log - 2026-06-05"' in content
243
+ assert "date: 2026-06-05" in content
244
+ assert "author: opsdev" in content
245
+ assert " - dev" in content
246
+
247
+ # Body preserved
248
+ assert "Custom body content." in content
249
+
250
+ # No auto-generated frontmatter prepended
251
+ assert content.count("---") == 2 # only the template's opening/closing
252
+
253
+
254
+ def test_generate_content_template_no_frontmatter(tmp_path):
255
+ """Template without frontmatter — entry has no frontmatter at all."""
256
+ template = tmp_path / "body-only.md"
257
+ template.write_text("# {{TITLE}}\n\nJust body content.")
258
+
259
+ config = {"template": str(template)}
260
+ content = generate_content(config, "2026-06-05")
261
+
262
+ # No frontmatter markers, just the rendered template
263
+ assert "---" not in content
264
+ assert "# Work Log - 2026-06-05" in content
265
+ assert "Just body content." in content
266
+
267
+
268
+ def test_render_template_case_sensitive(tmp_path):
269
+ """Lowercase placeholders are NOT substituted."""
270
+ template = tmp_path / "t.md"
271
+ template.write_text(
272
+ "title: {{title}}\n"
273
+ "date: {{date}}\n"
274
+ "author: {{author}}\n"
275
+ "tags: {{tags}}\n"
276
+ )
277
+
278
+ config = {"author": "opsdev", "default_tags": ["dev"]}
279
+ result = render_template(str(template), "2026-06-05", config)
280
+
281
+ # Lowercase placeholders left untouched
282
+ assert "{{title}}" in result
283
+ assert "{{date}}" in result
284
+ assert "{{author}}" in result
285
+ assert "{{tags}}" in result
@@ -1,28 +0,0 @@
1
- """Path resolution for worklog files."""
2
-
3
- from datetime import date
4
- from pathlib import Path
5
- from typing import Any
6
-
7
-
8
- def resolve_path(
9
- config: dict[str, Any],
10
- entry_date: date,
11
- ) -> Path:
12
- """Compute the target file path based on config structure."""
13
- base = Path(config.get("worklog_dir", "docs/worklog"))
14
- structure = config.get("structure", "year")
15
- suffix = config.get("suffix", "worklog")
16
-
17
- if structure == "flat":
18
- filename = f"{entry_date.isoformat()}-{suffix}.md"
19
- return base / filename
20
- elif structure == "year-month":
21
- year = entry_date.strftime("%Y")
22
- month = entry_date.strftime("%m")
23
- filename = f"{entry_date.strftime('%d-%m-%Y')}-{suffix}.md"
24
- return base / year / month / filename
25
- else: # "year" (default)
26
- year = entry_date.strftime("%Y")
27
- filename = f"{entry_date.strftime('%d-%m-%Y')}-{suffix}.md"
28
- return base / year / filename
@@ -1,71 +0,0 @@
1
- """Tests for CLI entry point."""
2
-
3
- from datetime import date
4
-
5
- from click.testing import CliRunner
6
-
7
- from worklog_opsdevnz.cli import main
8
-
9
-
10
- def test_main_creates_file(tmp_path, monkeypatch):
11
- """Running with no args creates today's worklog in tmp_path."""
12
- monkeypatch.chdir(tmp_path)
13
- runner = CliRunner()
14
- result = runner.invoke(main, [])
15
- assert result.exit_code == 0
16
- assert "Created" in result.output
17
- expected_leaf = (
18
- f"docs/worklog/{date.today().year}/{date.today():%d-%m-%Y}-worklog.md"
19
- )
20
- assert expected_leaf in result.output
21
-
22
-
23
- def test_main_opens_existing(tmp_path, monkeypatch):
24
- """Running again opens the existing file."""
25
- monkeypatch.chdir(tmp_path)
26
- runner = CliRunner()
27
- runner.invoke(main, [])
28
- result = runner.invoke(main, [])
29
- assert result.exit_code == 0
30
- assert "Opening existing" in result.output
31
-
32
-
33
- def test_main_help():
34
- """--help works."""
35
- runner = CliRunner()
36
- result = runner.invoke(main, ["--help"])
37
- assert result.exit_code == 0
38
- assert "--editor" in result.output
39
-
40
-
41
- def test_main_version():
42
- """--version prints the installed version."""
43
- runner = CliRunner()
44
- result = runner.invoke(main, ["--version"])
45
- assert result.exit_code == 0
46
- assert "worklog-opsdevnz, version" in result.output
47
- # Extract and verify it's a non-empty version string
48
- version_str = result.output.strip().split(", version ")[-1]
49
- assert version_str
50
-
51
-
52
- def test_main_editor_from_config(tmp_path, monkeypatch):
53
- """Editor in worklog.toml is resolved before $VISUAL/$EDITOR."""
54
- monkeypatch.chdir(tmp_path)
55
- (tmp_path / "worklog.toml").write_text('editor = "fake-editor-test"\n')
56
- runner = CliRunner()
57
- result = runner.invoke(main, [])
58
- assert result.exit_code == 0
59
- # config-based editor picked up but not found in PATH
60
- assert "Editor 'fake-editor-test' not found" in result.output
61
-
62
-
63
- def test_main_editor_cli_overrides_config(tmp_path, monkeypatch):
64
- """-e flag overrides the editor set in worklog.toml."""
65
- monkeypatch.chdir(tmp_path)
66
- (tmp_path / "worklog.toml").write_text('editor = "fake-editor-test"\n')
67
- runner = CliRunner()
68
- result = runner.invoke(main, ["-e", "override-editor-test"])
69
- assert result.exit_code == 0
70
- # CLI override takes priority over config
71
- assert "Editor 'override-editor-test' not found" in result.output
@@ -1,29 +0,0 @@
1
- """Tests for path resolution."""
2
-
3
- from datetime import date
4
-
5
- from worklog_opsdevnz.paths import resolve_path
6
-
7
-
8
- def test_resolve_path_flat():
9
- config = {"worklog_dir": "docs/worklog", "structure": "flat", "suffix": "worklog"}
10
- path = resolve_path(config, date(2026, 5, 23))
11
- assert str(path) == "docs/worklog/2026-05-23-worklog.md"
12
-
13
-
14
- def test_resolve_path_year():
15
- config = {"worklog_dir": "logs", "structure": "year", "suffix": "log"}
16
- path = resolve_path(config, date(2026, 5, 23))
17
- assert str(path) == "logs/2026/23-05-2026-log.md"
18
-
19
-
20
- def test_resolve_path_year_month():
21
- config = {"worklog_dir": "logs", "structure": "year-month", "suffix": "ops"}
22
- path = resolve_path(config, date(2026, 5, 23))
23
- assert str(path) == "logs/2026/05/23-05-2026-ops.md"
24
-
25
-
26
- def test_resolve_path_custom_suffix():
27
- config = {"structure": "year", "suffix": "retro"}
28
- path = resolve_path(config, date(2026, 1, 1))
29
- assert str(path).endswith("-retro.md")
@@ -1,99 +0,0 @@
1
- """Tests for frontmatter and body generation."""
2
-
3
- import pytest
4
-
5
- from worklog_opsdevnz.template import (
6
- generate_frontmatter,
7
- generate_body,
8
- generate_content,
9
- render_template,
10
- )
11
-
12
-
13
- def test_generate_frontmatter():
14
- config = {
15
- "author": "Test Author",
16
- "default_tags": ["internal", "test"],
17
- }
18
- fm = generate_frontmatter(config, "2026-05-23")
19
- assert "title: " in fm
20
- assert "Work Log - 2026-05-23" in fm
21
- assert "date: 2026-05-23" in fm
22
- assert "author: Test Author" in fm
23
- assert " - internal" in fm
24
- assert " - test" in fm
25
- assert "draft: false" in fm
26
- assert fm.startswith("---")
27
- assert fm.strip().endswith("---")
28
-
29
-
30
- def test_generate_body_with_sections():
31
- config = {
32
- "sections": [
33
- {"title": "Focus", "content": ""},
34
- {"title": "Notes", "content": "some note"},
35
- ]
36
- }
37
- body = generate_body(config)
38
- assert "## Focus" in body
39
- assert "## Notes" in body
40
- assert "some note" in body
41
-
42
-
43
- def test_generate_content_full():
44
- config = {
45
- "author": "opsdev",
46
- "default_tags": ["log"],
47
- "sections": [{"title": "Today", "content": ""}],
48
- }
49
- content = generate_content(config, "2026-05-23")
50
- assert content.startswith("---")
51
- assert "Work Log - 2026-05-23" in content
52
- assert "## Today" in content
53
-
54
-
55
- def test_render_template_with_placeholders(tmp_path):
56
- template = tmp_path / "my-template.md"
57
- template.write_text("# {{TITLE}}\n\nDate: {{DATE}}\n\nFree-form notes.")
58
-
59
- result = render_template(str(template), "2026-05-26")
60
- assert "# Work Log - 2026-05-26" in result
61
- assert "Date: 2026-05-26" in result
62
- assert "Free-form notes." in result
63
- assert "{{DATE}}" not in result
64
- assert "{{TITLE}}" not in result
65
-
66
-
67
- def test_render_template_file_not_found():
68
- with pytest.raises(FileNotFoundError):
69
- render_template("/nonexistent/template.md", "2026-05-26")
70
-
71
-
72
- def test_generate_content_with_template(tmp_path):
73
- template = tmp_path / "my-template.md"
74
- template.write_text("# {{TITLE}}\n\nDate: {{DATE}}")
75
-
76
- config = {
77
- "author": "opsdev",
78
- "default_tags": ["log"],
79
- "template": str(template),
80
- }
81
- content = generate_content(config, "2026-05-26")
82
- assert content.startswith("---")
83
- assert "Work Log - 2026-05-26" in content
84
- assert "author: opsdev" in content
85
- assert "# Work Log - 2026-05-26" in content
86
- assert "Date: 2026-05-26" in content
87
- # Frontmatter generated, body from template — not sections
88
- assert "## " not in content.split("---\n")[2] if "---\n" in content else True
89
-
90
-
91
- def test_generate_content_without_template():
92
- """No template field → built-in sections body, unchanged behaviour."""
93
- config = {
94
- "author": "opsdev",
95
- "default_tags": ["log"],
96
- "sections": [{"title": "Today", "content": ""}],
97
- }
98
- content = generate_content(config, "2026-05-26")
99
- assert "## Today" in content