1use std::{
2 ffi::OsStr,
3 fs,
4 io::{BufRead, BufReader},
5 path::{Path, PathBuf},
6 process::Command,
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use hj_core::{Handoff, HandoffItem, HandoffState, infer_priority, sanitize_name};
11use walkdir::WalkDir;
12
13#[derive(Debug, Clone)]
14pub struct RepoContext {
15 pub repo_root: PathBuf,
16 pub cwd: PathBuf,
17 pub base_name: String,
18}
19
20#[derive(Debug, Clone)]
21pub struct HandoffPaths {
22 pub repo_root: PathBuf,
23 pub ctx_dir: PathBuf,
24 pub handoff_path: PathBuf,
25 pub state_path: PathBuf,
26 pub rendered_path: PathBuf,
27 pub handover_path: PathBuf,
28 pub project: String,
29 pub base_name: String,
30}
31
32#[derive(Debug, Clone)]
33pub struct RefreshReport {
34 pub ctx_dir: PathBuf,
35 pub packages: Vec<String>,
36}
37
38#[derive(Debug, Clone)]
39pub struct SurveyHandoff {
40 pub path: PathBuf,
41 pub repo_root: PathBuf,
42 pub project_name: String,
43 pub branch: Option<String>,
44 pub build: Option<String>,
45 pub tests: Option<String>,
46 pub items: Vec<HandoffItem>,
47}
48
49#[derive(Debug, Clone, Eq, PartialEq)]
50pub struct TodoMarker {
51 pub path: PathBuf,
52 pub line: usize,
53 pub text: String,
54}
55
56pub fn discover(cwd: &Path) -> Result<RepoContext> {
57 let repo_root =
58 git_output(cwd, ["rev-parse", "--show-toplevel"]).context("not in a git repository")?;
59 let repo_root = PathBuf::from(repo_root.trim());
60 let cwd = fs::canonicalize(cwd).context("failed to canonicalize current directory")?;
61 let base_name = repo_root
62 .file_name()
63 .and_then(OsStr::to_str)
64 .ok_or_else(|| anyhow!("repo root has no basename"))?
65 .to_string();
66
67 Ok(RepoContext {
68 repo_root,
69 cwd,
70 base_name,
71 })
72}
73
74impl RepoContext {
75 pub fn project_name(&self) -> Result<String> {
76 derive_project_name(&self.cwd, &self.repo_root)
77 }
78
79 pub fn paths(&self, explicit_project: Option<&str>) -> Result<HandoffPaths> {
80 let project = explicit_project
81 .map(ToOwned::to_owned)
82 .unwrap_or(self.project_name()?);
83 let project = sanitize_name(&project);
84 let ctx_dir = self.repo_root.join(".ctx");
85 let handoff_path = ctx_dir.join(format!("HANDOFF.{project}.{}.yaml", self.base_name));
86 let state_path = ctx_dir.join(format!("HANDOFF.{project}.{}.state.yaml", self.base_name));
87 let rendered_path = ctx_dir.join("HANDOFF.md");
88 let handover_path = ctx_dir.join("HANDOVER.md");
89
90 Ok(HandoffPaths {
91 repo_root: self.repo_root.clone(),
92 ctx_dir,
93 handoff_path,
94 state_path,
95 rendered_path,
96 handover_path,
97 project,
98 base_name: self.base_name.clone(),
99 })
100 }
101
102 pub fn refresh(&self, force: bool) -> Result<RefreshReport> {
103 let ctx_dir = self.repo_root.join(".ctx");
104 let token = ctx_dir.join(".initialized");
105 if token.exists() && !force {
106 return Ok(RefreshReport {
107 ctx_dir,
108 packages: scan_package_names(&self.repo_root)?,
109 });
110 }
111
112 fs::create_dir_all(&ctx_dir).context("failed to create .ctx directory")?;
113 let today = today(&self.repo_root)?;
114 let branch = branch_name(&self.repo_root).unwrap_or_else(|_| "unknown".to_string());
115 let packages = scan_package_names(&self.repo_root)?;
116
117 for pkg in &packages {
118 let state_path = ctx_dir.join(format!("HANDOFF.{pkg}.{}.state.yaml", self.base_name));
119 if !state_path.exists() {
120 let state = HandoffState {
121 updated: Some(today.clone()),
122 branch: Some(branch.clone()),
123 build: Some("unknown".to_string()),
124 tests: Some("unknown".to_string()),
125 notes: None,
126 touched_files: Vec::new(),
127 extra: Default::default(),
128 };
129 fs::write(&state_path, serde_yaml::to_string(&state)?)
130 .with_context(|| format!("failed to write {}", state_path.display()))?;
131 }
132 }
133
134 write_gitignore_block(&self.repo_root)?;
135 fs::write(&token, format!("{today}\n"))
136 .with_context(|| format!("failed to write {}", token.display()))?;
137
138 Ok(RefreshReport { ctx_dir, packages })
139 }
140
141 pub fn migrate_root_handoff(&self, target: &Path) -> Result<Option<PathBuf>> {
142 let old = find_root_handoff(&self.repo_root)?;
143 let Some(old) = old else {
144 return Ok(None);
145 };
146
147 let parent = target
148 .parent()
149 .ok_or_else(|| anyhow!("target handoff has no parent directory"))?;
150 fs::create_dir_all(parent)?;
151
152 let status = Command::new("git")
153 .arg("-C")
154 .arg(&self.repo_root)
155 .arg("mv")
156 .arg(&old)
157 .arg(target)
158 .status();
159
160 match status {
161 Ok(result) if result.success() => Ok(Some(target.to_path_buf())),
162 _ => {
163 fs::rename(&old, target).with_context(|| {
164 format!(
165 "failed to move legacy handoff {} -> {}",
166 old.display(),
167 target.display()
168 )
169 })?;
170 Ok(Some(target.to_path_buf()))
171 }
172 }
173 }
174
175 pub fn working_tree_files(&self) -> Result<Vec<String>> {
176 let output = git_output(
177 &self.repo_root,
178 ["status", "--short", "--untracked-files=all"],
179 )?;
180 let mut files = Vec::new();
181 for line in output.lines() {
182 if line.len() < 4 {
183 continue;
184 }
185 let raw = line[3..].trim();
186 if raw.is_empty() {
187 continue;
188 }
189 let file = raw
190 .split(" -> ")
191 .last()
192 .map(str::trim)
193 .unwrap_or(raw)
194 .to_string();
195 if !files.iter().any(|existing| existing == &file) {
196 files.push(file);
197 }
198 }
199 Ok(files)
200 }
201}
202
203pub fn branch_name(repo_root: &Path) -> Result<String> {
204 Ok(git_output(repo_root, ["branch", "--show-current"])?
205 .trim()
206 .to_string())
207}
208
209pub fn current_short_head(repo_root: &Path) -> Result<String> {
210 Ok(git_output(repo_root, ["rev-parse", "--short", "HEAD"])?
211 .trim()
212 .to_string())
213}
214
215pub fn today(cwd: &Path) -> Result<String> {
216 Ok(command_output("date", cwd, ["+%Y-%m-%d"])?
217 .trim()
218 .to_string())
219}
220
221pub fn discover_handoffs(base: &Path, max_depth: usize) -> Result<Vec<SurveyHandoff>> {
222 let base = fs::canonicalize(base)
223 .with_context(|| format!("failed to canonicalize {}", base.display()))?;
224 let mut results = Vec::new();
225
226 for entry in WalkDir::new(&base)
227 .max_depth(max_depth)
228 .into_iter()
229 .filter_entry(|entry| !is_ignored_dir(entry.path()))
230 {
231 let entry = entry?;
232 if !entry.file_type().is_file() {
233 continue;
234 }
235
236 let path = entry.path();
237 if !is_handoff_file(path) {
238 continue;
239 }
240
241 let Some(repo_root) = repo_root_for(path.parent().unwrap_or(&base)) else {
242 continue;
243 };
244
245 let branch = branch_name(&repo_root)
246 .ok()
247 .filter(|value| !value.is_empty());
248
249 if path.extension().and_then(OsStr::to_str) == Some("yaml") {
250 let contents = fs::read_to_string(path)
251 .with_context(|| format!("failed to read {}", path.display()))?;
252 let handoff: Handoff = serde_yaml::from_str(&contents)
253 .with_context(|| format!("failed to parse {}", path.display()))?;
254 let project_name = handoff
255 .project
256 .clone()
257 .filter(|value| !value.is_empty())
258 .unwrap_or_else(|| {
259 derive_project_name(&repo_root, &repo_root).unwrap_or_else(|_| "unknown".into())
260 });
261 let items = handoff
262 .items
263 .into_iter()
264 .filter(|item| item.is_open_or_blocked())
265 .collect::<Vec<_>>();
266
267 let (build, tests) = read_state_fields(path)?;
268 results.push(SurveyHandoff {
269 path: path.to_path_buf(),
270 repo_root,
271 project_name,
272 branch,
273 build,
274 tests,
275 items,
276 });
277 continue;
278 }
279
280 let items = parse_markdown_handoff(path)?;
281 let project_name = derive_project_name(&repo_root, &repo_root)?;
282 results.push(SurveyHandoff {
283 path: path.to_path_buf(),
284 repo_root,
285 project_name,
286 branch,
287 build: None,
288 tests: None,
289 items,
290 });
291 }
292
293 results.sort_by(|left, right| left.path.cmp(&right.path));
294 Ok(results)
295}
296
297pub fn discover_todo_markers(base: &Path, max_depth: usize) -> Result<Vec<TodoMarker>> {
298 let base = fs::canonicalize(base)
299 .with_context(|| format!("failed to canonicalize {}", base.display()))?;
300 let mut markers = Vec::new();
301
302 for entry in WalkDir::new(&base)
303 .max_depth(max_depth)
304 .into_iter()
305 .filter_entry(|entry| !is_ignored_dir(entry.path()))
306 {
307 let entry = entry?;
308 if !entry.file_type().is_file() || !is_marker_file(entry.path()) {
309 continue;
310 }
311
312 let file = fs::File::open(entry.path())
313 .with_context(|| format!("failed to read {}", entry.path().display()))?;
314 for (idx, line) in BufReader::new(file).lines().enumerate() {
315 let line = line?;
316 if let Some(marker) = extract_marker(&line) {
317 markers.push(TodoMarker {
318 path: entry.path().to_path_buf(),
319 line: idx + 1,
320 text: marker.to_string(),
321 });
322 }
323 }
324 }
325
326 Ok(markers)
327}
328
329fn derive_project_name(cwd: &Path, repo_root: &Path) -> Result<String> {
330 if let Some(name) = manifest_name(cwd)? {
331 return Ok(sanitize_name(&name));
332 }
333 if let Some(name) = manifest_name(repo_root)? {
334 return Ok(sanitize_name(&name));
335 }
336
337 let name = cwd
338 .file_name()
339 .and_then(OsStr::to_str)
340 .ok_or_else(|| anyhow!("current directory has no basename"))?;
341 Ok(sanitize_name(name))
342}
343
344fn manifest_name(dir: &Path) -> Result<Option<String>> {
345 let cargo = dir.join("Cargo.toml");
346 if cargo.exists() {
347 let contents = fs::read_to_string(&cargo)
348 .with_context(|| format!("failed to read {}", cargo.display()))?;
349 let manifest: toml::Value = toml::from_str(&contents)
350 .with_context(|| format!("failed to parse {}", cargo.display()))?;
351 if let Some(name) = manifest
352 .get("package")
353 .and_then(|value| value.get("name"))
354 .and_then(toml::Value::as_str)
355 {
356 return Ok(Some(name.to_string()));
357 }
358 }
359
360 let pyproject = dir.join("pyproject.toml");
361 if pyproject.exists() {
362 let contents = fs::read_to_string(&pyproject)
363 .with_context(|| format!("failed to read {}", pyproject.display()))?;
364 let manifest: toml::Value = toml::from_str(&contents)
365 .with_context(|| format!("failed to parse {}", pyproject.display()))?;
366 let project_name = manifest
367 .get("project")
368 .and_then(|value| value.get("name"))
369 .and_then(toml::Value::as_str)
370 .or_else(|| {
371 manifest
372 .get("tool")
373 .and_then(|value| value.get("poetry"))
374 .and_then(|value| value.get("name"))
375 .and_then(toml::Value::as_str)
376 });
377 if let Some(name) = project_name {
378 return Ok(Some(name.to_string()));
379 }
380 }
381
382 let go_mod = dir.join("go.mod");
383 if go_mod.exists() {
384 let contents = fs::read_to_string(&go_mod)
385 .with_context(|| format!("failed to read {}", go_mod.display()))?;
386 for line in contents.lines() {
387 if let Some(module) = line.strip_prefix("module ") {
388 let name = module
389 .split('/')
390 .next_back()
391 .unwrap_or(module)
392 .trim()
393 .to_string();
394 if !name.is_empty() {
395 return Ok(Some(name));
396 }
397 }
398 }
399 }
400
401 Ok(None)
402}
403
404fn scan_package_names(repo_root: &Path) -> Result<Vec<String>> {
405 let mut packages = Vec::new();
406
407 for entry in WalkDir::new(repo_root)
408 .into_iter()
409 .filter_entry(|entry| !is_ignored_dir(entry.path()))
410 {
411 let entry = entry?;
412 if !entry.file_type().is_file() {
413 continue;
414 }
415
416 let Some(file_name) = entry.file_name().to_str() else {
417 continue;
418 };
419
420 let manifest_dir = entry.path().parent().unwrap_or(repo_root);
421 let maybe_name = match file_name {
422 "Cargo.toml" | "pyproject.toml" | "go.mod" => manifest_name(manifest_dir)?,
423 _ => None,
424 };
425
426 if let Some(name) = maybe_name {
427 let name = sanitize_name(&name);
428 if !packages.iter().any(|existing| existing == &name) {
429 packages.push(name);
430 }
431 }
432 }
433
434 if packages.is_empty() {
435 packages.push(
436 repo_root
437 .file_name()
438 .and_then(OsStr::to_str)
439 .map(sanitize_name)
440 .ok_or_else(|| anyhow!("repo root has no basename"))?,
441 );
442 }
443
444 packages.sort();
445 Ok(packages)
446}
447
448fn is_ignored_dir(path: &Path) -> bool {
449 matches!(
450 path.file_name().and_then(OsStr::to_str),
451 Some(".git" | "target" | "vendor" | "__pycache__")
452 )
453}
454
455fn is_handoff_file(path: &Path) -> bool {
456 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
457 return false;
458 };
459
460 (name.starts_with("HANDOFF.") && (name.ends_with(".yaml") || name.ends_with(".md")))
461 && !name.ends_with(".state.yaml")
462}
463
464fn is_marker_file(path: &Path) -> bool {
465 matches!(
466 path.extension().and_then(OsStr::to_str),
467 Some("rs" | "sh" | "py" | "toml")
468 )
469}
470
471fn extract_marker(line: &str) -> Option<&str> {
472 ["TODO:", "FIXME:", "HACK:", "XXX:"]
473 .into_iter()
474 .find(|needle| line.contains(needle))
475}
476
477fn read_state_fields(handoff_path: &Path) -> Result<(Option<String>, Option<String>)> {
478 let Some(name) = handoff_path.file_name().and_then(OsStr::to_str) else {
479 return Ok((None, None));
480 };
481 let state_name = name.replace(".yaml", ".state.yaml");
482 let state_path = handoff_path.with_file_name(state_name);
483 if !state_path.exists() {
484 return Ok((None, None));
485 }
486
487 let contents = fs::read_to_string(&state_path)
488 .with_context(|| format!("failed to read {}", state_path.display()))?;
489 let state: HandoffState = serde_yaml::from_str(&contents)
490 .with_context(|| format!("failed to parse {}", state_path.display()))?;
491 Ok((state.build, state.tests))
492}
493
494fn repo_root_for(dir: &Path) -> Option<PathBuf> {
495 git_output(dir, ["rev-parse", "--show-toplevel"])
496 .ok()
497 .map(|value| PathBuf::from(value.trim()))
498}
499
500fn parse_markdown_handoff(path: &Path) -> Result<Vec<HandoffItem>> {
501 let contents =
502 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
503 let mut items = Vec::new();
504 let mut in_section = false;
505
506 for line in contents.lines() {
507 let trimmed = line.trim();
508 let normalized = trimmed.trim_start_matches('#').trim().to_ascii_lowercase();
509 if matches!(
510 normalized.as_str(),
511 "known gaps" | "next up" | "parked" | "remaining work"
512 ) {
513 in_section = true;
514 continue;
515 }
516 if trimmed.starts_with('#') {
517 in_section = false;
518 continue;
519 }
520 if !in_section {
521 continue;
522 }
523 let bullet = trimmed
524 .strip_prefix("- ")
525 .or_else(|| trimmed.strip_prefix("* "))
526 .or_else(|| trimmed.strip_prefix("1. "));
527 let Some(title) = bullet else {
528 continue;
529 };
530 let priority = infer_priority(title, None);
531 items.push(HandoffItem {
532 id: format!("md-{}", items.len() + 1),
533 priority: Some(priority),
534 status: Some("open".into()),
535 title: title.to_string(),
536 ..HandoffItem::default()
537 });
538 }
539
540 Ok(items)
541}
542
543fn find_root_handoff(repo_root: &Path) -> Result<Option<PathBuf>> {
544 let mut matches = Vec::new();
545 for entry in fs::read_dir(repo_root)? {
546 let entry = entry?;
547 let path = entry.path();
548 if !path.is_file() {
549 continue;
550 }
551 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
552 continue;
553 };
554 if name.starts_with("HANDOFF.") && name.ends_with(".yaml") {
555 matches.push(path);
556 }
557 }
558 matches.sort();
559 Ok(matches.into_iter().next())
560}
561
562fn write_gitignore_block(repo_root: &Path) -> Result<()> {
563 let gitignore_path = repo_root.join(".gitignore");
564 let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
565 let block = [
566 "# handoff-begin",
567 ".ctx/*",
568 "!.ctx/HANDOFF.*.yaml",
569 ".ctx/HANDOFF.*.state.yaml",
570 "!.ctx/handoff.*.config.toml.example",
571 ".ctx/HANDOFF.*.*.state.yaml",
572 ".ctx/HANDOFF.hj.hj.state.yaml",
573 ".ctx/.initialized",
574 "# handoff-end",
575 ];
576
577 let mut output = Vec::new();
578 let mut in_block = false;
579 let mut replaced = false;
580
581 for line in existing.lines() {
582 match line {
583 "# handoff-begin" => {
584 if !replaced {
585 output.extend(block.iter().map(|value| (*value).to_string()));
586 replaced = true;
587 }
588 in_block = true;
589 }
590 "# handoff-end" => {
591 in_block = false;
592 }
593 _ if !in_block => output.push(line.to_string()),
594 _ => {}
595 }
596 }
597
598 if !replaced {
599 if !output.is_empty() && output.last().is_some_and(|line| !line.is_empty()) {
600 output.push(String::new());
601 }
602 output.extend(block.iter().map(|value| (*value).to_string()));
603 }
604
605 fs::write(&gitignore_path, output.join("\n") + "\n")
606 .with_context(|| format!("failed to write {}", gitignore_path.display()))?;
607 Ok(())
608}
609
610fn git_output<I, S>(cwd: &Path, args: I) -> Result<String>
611where
612 I: IntoIterator<Item = S>,
613 S: AsRef<OsStr>,
614{
615 command_output("git", cwd, args)
616}
617
618fn command_output<I, S>(program: &str, cwd: &Path, args: I) -> Result<String>
619where
620 I: IntoIterator<Item = S>,
621 S: AsRef<OsStr>,
622{
623 let output = Command::new(program)
624 .args(args)
625 .current_dir(cwd)
626 .output()
627 .with_context(|| format!("failed to run {program}"))?;
628
629 if !output.status.success() {
630 let stderr = String::from_utf8_lossy(&output.stderr);
631 bail!("{program} failed: {}", stderr.trim());
632 }
633
634 Ok(String::from_utf8_lossy(&output.stdout).to_string())
635}
636
637#[cfg(test)]
638mod tests {
639 use std::fs;
640
641 use super::{manifest_name, write_gitignore_block};
642
643 #[test]
644 fn parses_package_name_from_cargo_manifest() {
645 let dir = tempfile::tempdir().unwrap();
646 fs::write(
647 dir.path().join("Cargo.toml"),
648 "[package]\nname = \"hj-cli\"\nversion = \"0.1.0\"\n",
649 )
650 .unwrap();
651
652 assert_eq!(
653 manifest_name(dir.path()).unwrap().as_deref(),
654 Some("hj-cli")
655 );
656 }
657
658 #[test]
659 fn rewrites_managed_gitignore_block() {
660 let dir = tempfile::tempdir().unwrap();
661 let gitignore = dir.path().join(".gitignore");
662 fs::write(
663 &gitignore,
664 "target/\n# handoff-begin\nold\n# handoff-end\nnode_modules/\n",
665 )
666 .unwrap();
667
668 write_gitignore_block(dir.path()).unwrap();
669 let updated = fs::read_to_string(gitignore).unwrap();
670
671 assert!(updated.contains(".ctx/*"));
672 assert!(updated.contains(".ctx/HANDOFF.*.*.state.yaml"));
673 assert!(updated.contains(".ctx/HANDOFF.hj.hj.state.yaml"));
674 assert!(updated.contains("target/"));
675 assert!(updated.contains("node_modules/"));
676 assert!(!updated.contains("\nold\n"));
677 }
678}