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 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}