Skip to main content

hj_git/
lib.rs

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}