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