1use std::fs;
32use std::io;
33use std::path::{Path, PathBuf};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use crate::manifest;
37use crate::mapcmd;
38use crate::repo;
39use crate::walk;
40
41fn civil_from_days(z: i64) -> (i64, u32, u32) {
50 let z = z + 719_468;
51 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
52 let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
55 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (y + if m <= 2 { 1 } else { 0 }, m, d)
60}
61
62fn today() -> String {
63 let secs = SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .map(|d| d.as_secs())
66 .unwrap_or(0);
67 let days = (secs / 86_400) as i64;
68 let (y, m, d) = civil_from_days(days);
69 format!("{y:04}-{m:02}-{d:02}")
70}
71
72fn parse_init_args(args: &[String]) -> (Option<String>, Vec<String>) {
79 let mut label: Option<String> = None;
80 let mut scope: Vec<String> = Vec::new();
81 let mut i = 0usize;
82 while i < args.len() {
83 if args[i] == "--label" {
84 label = args.get(i + 1).cloned();
85 i += 2;
86 continue;
87 }
88 scope.push(args[i].clone());
89 i += 1;
90 }
91 (label, scope)
92}
93
94fn nested_git_repos(root: &Path) -> io::Result<Vec<String>> {
103 let mut names = Vec::new();
104 for entry in fs::read_dir(root)? {
105 let entry = entry?;
106 if entry.file_type()?.is_dir() {
107 let name = entry.file_name().to_string_lossy().into_owned();
108 if root.join(&name).join(".git").exists() {
109 names.push(name);
110 }
111 }
112 }
113 names.sort();
114 Ok(names)
115}
116
117fn ensure_scout_excluded(info_dir: &Path) -> io::Result<()> {
127 fs::create_dir_all(info_dir)?;
128 let exclude_path = info_dir.join("exclude");
129 let body = if exclude_path.exists() {
130 String::from_utf8_lossy(&fs::read(&exclude_path)?).into_owned()
131 } else {
132 String::new()
133 };
134 let already_listed = body.split('\n').any(|l| l.trim() == ".scout/");
135 if !already_listed {
136 let prefix = if body.is_empty() || body.ends_with('\n') {
137 ""
138 } else {
139 "\n"
140 };
141 use io::Write as _;
142 let mut f = fs::OpenOptions::new()
143 .create(true)
144 .append(true)
145 .open(&exclude_path)?;
146 write!(f, "{prefix}.scout/\n")?;
147 }
148 Ok(())
149}
150
151fn set_field(value: manifest::Value, key: &str, new_value: manifest::Value) -> manifest::Value {
164 let fields = value.as_object().unwrap_or(&[]).to_vec();
165 let mut out = Vec::with_capacity(fields.len() + 1);
166 let mut replaced = false;
167 for (k, v) in fields {
168 if k == key {
169 out.push((k, new_value.clone()));
170 replaced = true;
171 } else {
172 out.push((k, v));
173 }
174 }
175 if !replaced {
176 out.push((key.to_string(), new_value));
177 }
178 manifest::Value::Object(out)
179}
180
181fn read_registry_value() -> Result<manifest::Value, String> {
189 let path = repo::registry_path();
190 if !path.exists() {
191 return Ok(manifest::Value::object(vec![(
192 "roots",
193 manifest::Value::array(vec![]),
194 )]));
195 }
196 let bytes = fs::read(&path)
197 .map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
198 let text = String::from_utf8_lossy(&bytes).into_owned();
199 let value: manifest::Value = serde_json::from_str(&text)
200 .map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
201 match value.get("roots") {
202 Some(manifest::Value::Array(_)) => Ok(value),
203 _ => Err(format!(
204 "registry at {} has no \"roots\" array",
205 path.display()
206 )),
207 }
208}
209
210fn write_registry_value(value: &manifest::Value) -> Result<(), String> {
219 let path = repo::registry_path();
220 let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
221 let body = format!("{json}\n");
222
223 if let Some(dir) = path.parent() {
224 fs::create_dir_all(dir).map_err(|e| e.to_string())?;
225 }
226 let suffix = SystemTime::now()
227 .duration_since(UNIX_EPOCH)
228 .map(|d| d.as_nanos())
229 .unwrap_or(0);
230 let tmp_name = format!(
231 "{}.tmp.{}.{suffix}",
232 path.file_name()
233 .and_then(|n| n.to_str())
234 .unwrap_or("repos.json"),
235 std::process::id()
236 );
237 let tmp_path = path.with_file_name(tmp_name);
238
239 if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
240 let _ = fs::remove_file(&tmp_path);
241 return Err(e.to_string());
242 }
243 if let Err(e) = fs::rename(&tmp_path, &path) {
244 let _ = fs::remove_file(&tmp_path);
245 return Err(e.to_string());
246 }
247 Ok(())
248}
249
250fn new_entry_value(
251 root_abs: &str,
252 kind: &str,
253 label: Option<&str>,
254 scope: &[String],
255 stamp: &str,
256) -> manifest::Value {
257 manifest::Value::object(vec![
258 ("root", manifest::Value::string(root_abs)),
259 ("kind", manifest::Value::string(kind)),
260 (
261 "label",
262 label
263 .map(manifest::Value::string)
264 .unwrap_or(manifest::Value::Null),
265 ),
266 (
267 "scope",
268 manifest::Value::array(
269 scope
270 .iter()
271 .map(|s| manifest::Value::string(s.clone()))
272 .collect(),
273 ),
274 ),
275 ("initialized", manifest::Value::string(stamp)),
276 ("last_seen", manifest::Value::string(stamp)),
277 ])
278}
279
280fn update_entry_value(
284 entry: manifest::Value,
285 kind: &str,
286 label: Option<&str>,
287 scope: &[String],
288 stamp: &str,
289) -> manifest::Value {
290 let mut e = set_field(entry, "kind", manifest::Value::string(kind));
291 if let Some(l) = label {
292 if !l.is_empty() {
293 e = set_field(e, "label", manifest::Value::string(l));
294 }
295 }
296 if !scope.is_empty() {
297 e = set_field(
298 e,
299 "scope",
300 manifest::Value::array(
301 scope
302 .iter()
303 .map(|s| manifest::Value::string(s.clone()))
304 .collect(),
305 ),
306 );
307 }
308 e = set_field(e, "last_seen", manifest::Value::string(stamp));
309 e
310}
311
312fn register_root(
316 root_abs: &str,
317 kind: &str,
318 label: Option<&str>,
319 scope: &[String],
320) -> Result<(), String> {
321 let registry = read_registry_value()?;
322 let roots: Vec<manifest::Value> = match registry.get("roots") {
323 Some(manifest::Value::Array(items)) => items.clone(),
324 _ => Vec::new(),
325 };
326
327 let stamp = today();
328 let mut found = false;
329 let mut new_roots = Vec::with_capacity(roots.len() + 1);
330 for entry in roots {
331 if entry.get("root").and_then(manifest::Value::as_str) == Some(root_abs) {
332 found = true;
333 new_roots.push(update_entry_value(entry, kind, label, scope, &stamp));
334 } else {
335 new_roots.push(entry);
336 }
337 }
338 if !found {
339 new_roots.push(new_entry_value(root_abs, kind, label, scope, &stamp));
340 }
341
342 let updated_registry = set_field(registry, "roots", manifest::Value::Array(new_roots));
343 write_registry_value(&updated_registry)
344}
345
346pub fn cmd_init(cwd: &Path, args: &[String]) -> (i32, String) {
354 let (label, scope) = parse_init_args(args);
355 let git_root = repo::find_repo_root(cwd);
356 let root = git_root.clone().unwrap_or_else(|| repo::resolve_path(cwd));
357
358 if git_root.is_none() {
359 match nested_git_repos(&root) {
360 Ok(nested) if !nested.is_empty() => {
361 return (
362 2,
363 format!(
364 "refusing to init {}: it contains git repos ({}).\nA .scout above several repos has no single HEAD, so manifest staleness detection silently stops firing. Run devscout init inside each repo instead.",
365 root.display(),
366 nested.join(", "),
367 ),
368 );
369 }
370 Ok(_) => {}
371 Err(e) => return (1, format!("error: {e}")),
372 }
373 }
374
375 let scope_note = if scope.is_empty() {
376 String::new()
377 } else {
378 format!(", scope {}", scope.join(", "))
379 };
380 let scout_dir = repo::scout_dir(&root);
381 let root_abs = root.to_string_lossy().into_owned();
382
383 if let Some(git_root) = &git_root {
384 let common = match repo::git_common_dir(git_root) {
388 Some(c) => c,
389 None => {
394 return (
395 1,
396 format!(
397 "error: unable to resolve git common dir for {}",
398 git_root.display()
399 ),
400 )
401 }
402 };
403 if let Err(e) = ensure_scout_excluded(&common.join("info")) {
404 return (1, format!("error: {e}"));
405 }
406 if let Err(e) = fs::create_dir_all(&scout_dir) {
407 return (1, format!("error: {e}"));
408 }
409 if let Err(e) = register_root(&root_abs, "git", label.as_deref(), &scope) {
410 return (1, format!("error: {e}"));
411 }
412 return (
413 0,
414 format!(
415 "devscout initialized at {}{scope_note}",
416 scout_dir.display()
417 ),
418 );
419 }
420
421 if let Err(e) = fs::create_dir_all(&scout_dir) {
422 return (1, format!("error: {e}"));
423 }
424 if let Err(e) = register_root(&root_abs, "plain", label.as_deref(), &scope) {
425 return (1, format!("error: {e}"));
426 }
427 (
428 0,
429 format!(
430 "devscout initialized at {} (non-git root: {}){scope_note}",
431 scout_dir.display(),
432 root.display()
433 ),
434 )
435}
436
437const FULLY_SUPPORTED_EXT: &[&str] = &[".cs"];
449const INDEXED_AND_GRAPHED_EXT: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
450
451fn extract_rust_only_flags(args: &[String]) -> (bool, bool, Vec<String>) {
456 let mut no_hooks = false;
457 let mut no_map = false;
458 let mut rest = Vec::with_capacity(args.len());
459 for a in args {
460 match a.as_str() {
461 "--no-hooks" => no_hooks = true,
462 "--no-map" => no_map = true,
463 _ => rest.push(a.clone()),
464 }
465 }
466 (no_hooks, no_map, rest)
467}
468
469pub fn cmd_init_full(cwd: &Path, args: &[String]) -> (i32, String) {
479 let (no_hooks, no_map, parity_args) = extract_rust_only_flags(args);
480 let (code, out) = cmd_init(cwd, &parity_args);
481 if code != 0 {
482 return (code, out);
483 }
484
485 let git_root = repo::find_repo_root(cwd);
489 let root = git_root.unwrap_or_else(|| repo::resolve_path(cwd));
490
491 let lines = [
492 out,
493 census_line(&root),
494 hooks_line(no_hooks),
495 map_line(&root, no_map),
496 ];
497 (0, lines.join("\n"))
498}
499
500fn census_line(root: &Path) -> String {
510 let files = match walk::list_source_files(root, &[".".to_string()]) {
511 Ok(f) => f,
512 Err(e) => return format!("languages: error walking sources ({e})"),
513 };
514 if files.is_empty() {
515 return "languages: no source files found".to_string();
516 }
517
518 let mut counts: Vec<(String, usize)> = Vec::new();
519 for f in &files {
520 let ext = match f.rfind('.') {
521 Some(i) => f[i..].to_string(),
522 None => String::new(),
523 };
524 match counts.iter_mut().find(|(e, _)| *e == ext) {
525 Some(entry) => entry.1 += 1,
526 None => counts.push((ext, 1)),
527 }
528 }
529 counts.sort();
530 let (fully_supported, other): (Vec<_>, Vec<_>) = counts
531 .into_iter()
532 .partition(|(e, _)| FULLY_SUPPORTED_EXT.contains(&e.as_str()));
533 let (indexed_and_graphed, not_indexed): (Vec<_>, Vec<_>) = other
534 .into_iter()
535 .partition(|(e, _)| INDEXED_AND_GRAPHED_EXT.contains(&e.as_str()));
536 let fmt_group = |g: &[(String, usize)]| {
537 g.iter()
538 .map(|(e, c)| format!("{c} {e}"))
539 .collect::<Vec<_>>()
540 .join(", ")
541 };
542
543 let mut groups = Vec::new();
544 if !fully_supported.is_empty() {
545 groups.push(format!("{} (fully supported)", fmt_group(&fully_supported)));
546 }
547 if !indexed_and_graphed.is_empty() {
548 groups.push(format!(
549 "{} (indexed and graphed, narrower edge coverage)",
550 fmt_group(&indexed_and_graphed)
551 ));
552 }
553 if !not_indexed.is_empty() {
554 groups.push(format!(
555 "{} (present, not indexed)",
556 fmt_group(¬_indexed)
557 ));
558 }
559 format!("languages: {}", groups.join("; "))
560}
561
562enum HookOutcome {
575 AlreadyInstalled,
576 Installed {
577 added: Vec<&'static str>,
578 backup: PathBuf,
579 },
580 NeedsManualInsert {
581 reason: String,
582 snippet: String,
583 },
584}
585
586fn hooks_line(no_hooks: bool) -> String {
587 if no_hooks {
588 return "hooks: skipped (--no-hooks)".to_string();
589 }
590 match install_hooks() {
591 Ok(HookOutcome::AlreadyInstalled) => "hooks: already installed (idempotent)".to_string(),
592 Ok(HookOutcome::Installed { added, backup }) => {
593 format!(
594 "hooks: installed ({}); backup {}",
595 added.join(", "),
596 backup.display()
597 )
598 }
599 Ok(HookOutcome::NeedsManualInsert { reason, snippet }) => {
600 format!("hooks: {reason} -- add manually:\n{snippet}")
601 }
602 Err(e) => format!("hooks: error: {e}"),
603 }
604}
605
606fn settings_path() -> Result<PathBuf, String> {
611 let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
612 Ok(PathBuf::from(home).join(".claude").join("settings.json"))
613}
614
615fn current_binary_path() -> Result<String, String> {
621 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
622 let exe = fs::canonicalize(&exe).unwrap_or(exe);
623 Ok(exe.to_string_lossy().into_owned())
624}
625
626fn install_hooks() -> Result<HookOutcome, String> {
627 let bin = current_binary_path()?;
628 let path = settings_path()?;
629
630 if !path.exists() {
631 return Ok(HookOutcome::NeedsManualInsert {
632 reason: format!("no settings file at {}", path.display()),
633 snippet: hook_snippet(&bin),
634 });
635 }
636
637 let bytes = fs::read(&path).map_err(|e| e.to_string())?;
638 let text = String::from_utf8_lossy(&bytes).into_owned();
639 let value: manifest::Value = match serde_json::from_str(&text) {
640 Ok(v) => v,
641 Err(_) => {
642 return Ok(HookOutcome::NeedsManualInsert {
643 reason: format!("settings file at {} is not valid JSON", path.display()),
644 snippet: hook_snippet(&bin),
645 })
646 }
647 };
648
649 if !matches!(value, manifest::Value::Object(_)) || !hooks_shape_ok(&value) {
650 return Ok(HookOutcome::NeedsManualInsert {
651 reason: format!(
652 "settings file at {} has an unexpected shape",
653 path.display()
654 ),
655 snippet: hook_snippet(&bin),
656 });
657 }
658
659 let (updated, added) = merge_hooks(value, &bin);
660 if added.is_empty() {
661 return Ok(HookOutcome::AlreadyInstalled);
662 }
663
664 let backup = backup_settings(&path, &bytes)?;
669 write_settings(&path, &updated)?;
670 Ok(HookOutcome::Installed { added, backup })
671}
672
673fn hooks_shape_ok(value: &manifest::Value) -> bool {
679 let Some(hooks) = value.get("hooks") else {
680 return true;
681 };
682 let manifest::Value::Object(_) = hooks else {
683 return false;
684 };
685 let Some(post_tool_use) = hooks.get("PostToolUse") else {
686 return true;
687 };
688 let manifest::Value::Array(entries) = post_tool_use else {
689 return false;
690 };
691 entries.iter().all(entry_shape_ok)
692}
693
694fn entry_shape_ok(entry: &manifest::Value) -> bool {
700 if !matches!(entry, manifest::Value::Object(_)) {
701 return false;
702 }
703 if let Some(matcher) = entry.get("matcher") {
704 if !matches!(matcher, manifest::Value::String(_)) {
705 return false;
706 }
707 }
708 let Some(hooks) = entry.get("hooks") else {
709 return true;
710 };
711 let manifest::Value::Array(items) = hooks else {
712 return false;
713 };
714 items.iter().all(|item| {
715 if !matches!(item, manifest::Value::Object(_)) {
716 return false;
717 }
718 if let Some(t) = item.get("type") {
719 if !matches!(t, manifest::Value::String(_)) {
720 return false;
721 }
722 }
723 if let Some(c) = item.get("command") {
724 if !matches!(c, manifest::Value::String(_)) {
725 return false;
726 }
727 }
728 true
729 })
730}
731
732fn already_has_hook_command(entries: &[manifest::Value], marker: &str) -> bool {
738 entries.iter().any(|entry| {
739 let Some(manifest::Value::Array(items)) = entry.get("hooks") else {
740 return false;
741 };
742 items.iter().any(|item| {
743 item.get("command")
744 .and_then(manifest::Value::as_str)
745 .is_some_and(|c| c.contains("scout") && c.contains(marker))
746 })
747 })
748}
749
750fn hook_entry(matcher: &str, bin: &str, subcmd: &str) -> manifest::Value {
751 manifest::Value::object(vec![
752 ("matcher", manifest::Value::string(matcher)),
753 (
754 "hooks",
755 manifest::Value::array(vec![manifest::Value::object(vec![
756 ("type", manifest::Value::string("command")),
757 (
758 "command",
759 manifest::Value::string(format!("{bin} hook {subcmd}")),
760 ),
761 ])]),
762 ),
763 ])
764}
765
766fn merge_hooks(value: manifest::Value, bin: &str) -> (manifest::Value, Vec<&'static str>) {
773 let hooks_val = value
774 .get("hooks")
775 .cloned()
776 .unwrap_or_else(|| manifest::Value::object(vec![]));
777 let ptu_val = hooks_val
778 .get("PostToolUse")
779 .cloned()
780 .unwrap_or_else(|| manifest::Value::array(vec![]));
781 let mut entries = match ptu_val {
782 manifest::Value::Array(items) => items,
783 _ => Vec::new(),
784 };
785
786 let mut added: Vec<&'static str> = Vec::new();
787 if !already_has_hook_command(&entries, "hook read") {
788 entries.push(hook_entry("Read", bin, "read"));
789 added.push("Read");
790 }
791 if !already_has_hook_command(&entries, "hook bash") {
792 entries.push(hook_entry("Bash", bin, "bash"));
793 added.push("Bash");
794 }
795
796 let updated_hooks = set_field(hooks_val, "PostToolUse", manifest::Value::Array(entries));
797 let updated = set_field(value, "hooks", updated_hooks);
798 (updated, added)
799}
800
801fn hook_snippet(bin: &str) -> String {
807 let v = manifest::Value::object(vec![(
808 "hooks",
809 manifest::Value::object(vec![(
810 "PostToolUse",
811 manifest::Value::array(vec![
812 hook_entry("Read", bin, "read"),
813 hook_entry("Bash", bin, "bash"),
814 ]),
815 )]),
816 )]);
817 serde_json::to_string_pretty(&v).unwrap_or_default()
818}
819
820fn now_stamp() -> String {
824 let dur = SystemTime::now()
825 .duration_since(UNIX_EPOCH)
826 .unwrap_or_default();
827 let secs = dur.as_secs();
828 let days = (secs / 86_400) as i64;
829 let (y, m, d) = civil_from_days(days);
830 let sod = secs % 86_400;
831 let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
832 format!(
833 "{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}-{:09}",
834 dur.subsec_nanos()
835 )
836}
837
838fn backup_settings(path: &Path, original_bytes: &[u8]) -> Result<PathBuf, String> {
844 let name = path
845 .file_name()
846 .and_then(|n| n.to_str())
847 .unwrap_or("settings.json");
848 let backup_path = path.with_file_name(format!("{name}.bak.{}", now_stamp()));
849 fs::write(&backup_path, original_bytes).map_err(|e| e.to_string())?;
850 Ok(backup_path)
851}
852
853fn write_settings(path: &Path, value: &manifest::Value) -> Result<(), String> {
857 let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
858 let body = format!("{json}\n");
859
860 let suffix = SystemTime::now()
861 .duration_since(UNIX_EPOCH)
862 .map(|d| d.as_nanos())
863 .unwrap_or(0);
864 let tmp_name = format!(
865 "{}.tmp.{}.{suffix}",
866 path.file_name()
867 .and_then(|n| n.to_str())
868 .unwrap_or("settings.json"),
869 std::process::id()
870 );
871 let tmp_path = path.with_file_name(tmp_name);
872
873 if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
874 let _ = fs::remove_file(&tmp_path);
875 return Err(e.to_string());
876 }
877 if let Err(e) = fs::rename(&tmp_path, path) {
878 let _ = fs::remove_file(&tmp_path);
879 return Err(e.to_string());
880 }
881 Ok(())
882}
883
884fn map_line(root: &Path, no_map: bool) -> String {
893 if no_map {
894 return "map: skipped (--no-map)".to_string();
895 }
896 match mapcmd::map_repo(root, &[], mapcmd::MapOptions::from_env()) {
897 Ok(report) => format!("map: {}", report.summary_line()),
898 Err(e) => format!("map: error: {e}"),
899 }
900}
901
902#[cfg(test)]
920mod tests {
921 use super::*;
922 use std::sync::atomic::{AtomicU64, Ordering};
923
924 static COUNTER: AtomicU64 = AtomicU64::new(0);
925
926 fn temp_dir(prefix: &str) -> PathBuf {
927 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
928 let dir = std::env::temp_dir().join(format!(
929 "scout-initcmd-rs-{prefix}-{}-{n}",
930 std::process::id()
931 ));
932 fs::create_dir_all(&dir).expect("create temp dir");
933 dir
934 }
935
936 #[test]
939 fn no_args_is_no_label_no_scope() {
940 let (label, scope) = parse_init_args(&[]);
941 assert_eq!(label, None);
942 assert!(scope.is_empty());
943 }
944
945 #[test]
946 fn label_flag_consumes_its_value() {
947 let args: Vec<String> = ["--label", "backend-cs"]
948 .iter()
949 .map(|s| s.to_string())
950 .collect();
951 let (label, scope) = parse_init_args(&args);
952 assert_eq!(label, Some("backend-cs".to_string()));
953 assert!(scope.is_empty());
954 }
955
956 #[test]
957 fn label_as_last_token_with_no_value_is_none() {
958 let args: Vec<String> = ["src", "--label"].iter().map(|s| s.to_string()).collect();
959 let (label, scope) = parse_init_args(&args);
960 assert_eq!(label, None);
961 assert_eq!(scope, vec!["src".to_string()]);
962 }
963
964 #[test]
965 fn scope_dirs_collected_in_order_around_label() {
966 let args: Vec<String> = ["src", "--label", "l", "lib", "app"]
967 .iter()
968 .map(|s| s.to_string())
969 .collect();
970 let (label, scope) = parse_init_args(&args);
971 assert_eq!(label, Some("l".to_string()));
972 assert_eq!(
973 scope,
974 vec!["src".to_string(), "lib".to_string(), "app".to_string()]
975 );
976 }
977
978 #[test]
981 fn civil_from_days_epoch_is_1970_01_01() {
982 assert_eq!(civil_from_days(0), (1970, 1, 1));
983 }
984
985 #[test]
986 fn civil_from_days_known_reference_points() {
987 assert_eq!(civil_from_days(11_017), (2000, 3, 1));
992 assert_eq!(civil_from_days(19_723), (2024, 1, 1));
996 assert_eq!(civil_from_days(19_723 + 59), (2024, 2, 29));
998 }
999
1000 #[test]
1001 fn today_is_well_formed_yyyy_mm_dd() {
1002 let t = today();
1003 assert_eq!(t.len(), 10);
1004 assert_eq!(t.as_bytes()[4], b'-');
1005 assert_eq!(t.as_bytes()[7], b'-');
1006 assert!(t.chars().all(|c| c.is_ascii_digit() || c == '-'));
1007 }
1008
1009 #[test]
1012 fn exclude_created_fresh_when_missing() {
1013 let dir = temp_dir("exclude-fresh");
1014 let info_dir = dir.join("info");
1015 ensure_scout_excluded(&info_dir).unwrap();
1016 let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1017 assert_eq!(body, ".scout/\n");
1018 }
1019
1020 #[test]
1021 fn exclude_appended_after_existing_content_without_trailing_newline() {
1022 let dir = temp_dir("exclude-append-no-nl");
1023 let info_dir = dir.join("info");
1024 fs::create_dir_all(&info_dir).unwrap();
1025 fs::write(info_dir.join("exclude"), "*.log").unwrap();
1026 ensure_scout_excluded(&info_dir).unwrap();
1027 let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1028 assert_eq!(body, "*.log\n.scout/\n");
1029 }
1030
1031 #[test]
1032 fn exclude_appended_after_existing_content_with_trailing_newline() {
1033 let dir = temp_dir("exclude-append-nl");
1034 let info_dir = dir.join("info");
1035 fs::create_dir_all(&info_dir).unwrap();
1036 fs::write(info_dir.join("exclude"), "*.log\n").unwrap();
1037 ensure_scout_excluded(&info_dir).unwrap();
1038 let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1039 assert_eq!(body, "*.log\n.scout/\n");
1040 }
1041
1042 #[test]
1043 fn exclude_is_idempotent_when_already_listed() {
1044 let dir = temp_dir("exclude-idempotent");
1045 let info_dir = dir.join("info");
1046 fs::create_dir_all(&info_dir).unwrap();
1047 fs::write(info_dir.join("exclude"), "*.log\n.scout/\nmore\n").unwrap();
1048 ensure_scout_excluded(&info_dir).unwrap();
1049 let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1050 assert_eq!(body, "*.log\n.scout/\nmore\n", "must not double-append");
1051 }
1052
1053 #[test]
1054 fn exclude_matches_on_trimmed_line_not_exact_bytes() {
1055 let dir = temp_dir("exclude-trim-match");
1058 let info_dir = dir.join("info");
1059 fs::create_dir_all(&info_dir).unwrap();
1060 fs::write(info_dir.join("exclude"), " .scout/ \n").unwrap();
1061 ensure_scout_excluded(&info_dir).unwrap();
1062 let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1063 assert_eq!(
1064 body, " .scout/ \n",
1065 "already-listed (after trim) must not append again"
1066 );
1067 }
1068
1069 #[test]
1072 fn set_field_overwrites_in_place_preserving_position() {
1073 let v = manifest::Value::object(vec![
1074 ("a", manifest::Value::string("1")),
1075 ("b", manifest::Value::string("2")),
1076 ]);
1077 let out = set_field(v, "a", manifest::Value::string("9"));
1078 let fields = out.as_object().unwrap();
1079 assert_eq!(fields[0].0, "a");
1080 assert_eq!(out.get("a").and_then(manifest::Value::as_str), Some("9"));
1081 assert_eq!(fields[1].0, "b");
1082 }
1083
1084 #[test]
1085 fn set_field_appends_when_absent() {
1086 let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
1087 let out = set_field(v, "z", manifest::Value::string("new"));
1088 let fields = out.as_object().unwrap();
1089 assert_eq!(fields.last().unwrap().0, "z");
1090 }
1091
1092 #[test]
1095 fn nested_git_repos_finds_immediate_subdirs_with_dot_git_only() {
1096 let root = temp_dir("nested-detect");
1097 fs::create_dir_all(root.join("repo-a/.git")).unwrap();
1098 fs::create_dir_all(root.join("repo-b/.git")).unwrap();
1099 fs::create_dir_all(root.join("plain-dir")).unwrap();
1100 fs::create_dir_all(root.join("repo-a/nested-repo/.git")).unwrap(); let found = nested_git_repos(&root).unwrap();
1103 assert_eq!(
1104 found,
1105 vec!["repo-a".to_string(), "repo-b".to_string()],
1106 "sorted, immediate subdirs only"
1107 );
1108 }
1109
1110 #[test]
1111 fn nested_git_repos_empty_when_none_present() {
1112 let root = temp_dir("nested-none");
1113 fs::create_dir_all(root.join("plain-dir")).unwrap();
1114 assert!(nested_git_repos(&root).unwrap().is_empty());
1115 }
1116
1117 #[test]
1120 fn update_entry_ignores_falsy_label_and_empty_scope() {
1121 let entry = new_entry_value(
1122 "/x",
1123 "git",
1124 Some("orig"),
1125 &["src".to_string()],
1126 "2026-01-01",
1127 );
1128 let updated = update_entry_value(entry, "git", None, &[], "2026-01-02");
1129 assert_eq!(
1130 updated.get("label").and_then(manifest::Value::as_str),
1131 Some("orig"),
1132 "no label given -- keeps existing"
1133 );
1134 let scope = updated.get("scope").unwrap();
1135 assert!(
1136 matches!(scope, manifest::Value::Array(items) if items.len() == 1),
1137 "empty scope given -- keeps existing"
1138 );
1139 assert_eq!(
1140 updated.get("last_seen").and_then(manifest::Value::as_str),
1141 Some("2026-01-02")
1142 );
1143 }
1144
1145 #[test]
1146 fn update_entry_overwrites_truthy_label_and_nonempty_scope() {
1147 let entry = new_entry_value(
1148 "/x",
1149 "git",
1150 Some("orig"),
1151 &["src".to_string()],
1152 "2026-01-01",
1153 );
1154 let updated = update_entry_value(
1155 entry,
1156 "git",
1157 Some("fresh"),
1158 &["lib".to_string(), "app".to_string()],
1159 "2026-01-02",
1160 );
1161 assert_eq!(
1162 updated.get("label").and_then(manifest::Value::as_str),
1163 Some("fresh")
1164 );
1165 let scope = updated.get("scope").unwrap();
1166 assert!(matches!(scope, manifest::Value::Array(items) if items.len() == 2));
1167 }
1168
1169 #[test]
1176 fn extract_rust_only_flags_strips_no_hooks_and_no_map_only() {
1177 let args: Vec<String> = ["--no-hooks", "src", "--no-map", "--label", "l"]
1178 .iter()
1179 .map(|s| s.to_string())
1180 .collect();
1181 let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
1182 assert!(no_hooks);
1183 assert!(no_map);
1184 assert_eq!(
1185 rest,
1186 vec!["src".to_string(), "--label".to_string(), "l".to_string()],
1187 "everything else passes through untouched, in order"
1188 );
1189 }
1190
1191 #[test]
1192 fn extract_rust_only_flags_defaults_false_when_absent() {
1193 let args: Vec<String> = ["--label", "l", "src"]
1194 .iter()
1195 .map(|s| s.to_string())
1196 .collect();
1197 let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
1198 assert!(!no_hooks);
1199 assert!(!no_map);
1200 assert_eq!(rest, args);
1201 }
1202
1203 #[test]
1206 fn census_line_reports_all_three_support_tiers() {
1207 let dir = temp_dir("census-mixed");
1208 fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
1209 fs::write(dir.join("B.cs"), "namespace X { class B {} }").unwrap();
1210 fs::write(dir.join("c.ts"), "export const x = 1;").unwrap();
1211 fs::write(dir.join("d.md"), "# doc").unwrap();
1212 let line = census_line(&dir);
1213 assert_eq!(line, "languages: 2 .cs (fully supported); 1 .ts (indexed and graphed, narrower edge coverage); 1 .md (present, not indexed)");
1214 }
1215
1216 #[test]
1217 fn census_line_all_supported_omits_the_unsupported_clause() {
1218 let dir = temp_dir("census-cs-only");
1219 fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
1220 let line = census_line(&dir);
1221 assert_eq!(line, "languages: 1 .cs (fully supported)");
1222 }
1223
1224 #[test]
1225 fn census_line_no_supported_still_reports_unsupported() {
1226 let dir = temp_dir("census-unsupported-only");
1227 fs::write(dir.join("a.md"), "# doc").unwrap();
1228 let line = census_line(&dir);
1229 assert_eq!(line, "languages: 1 .md (present, not indexed)");
1230 }
1231
1232 #[test]
1233 fn census_line_empty_repo_reports_no_source_files() {
1234 let dir = temp_dir("census-empty");
1235 assert_eq!(census_line(&dir), "languages: no source files found");
1236 }
1237
1238 #[test]
1241 fn hooks_shape_ok_on_absent_hooks_key() {
1242 let v = manifest::Value::object(vec![("other", manifest::Value::string("x"))]);
1243 assert!(hooks_shape_ok(&v));
1244 }
1245
1246 #[test]
1247 fn hooks_shape_ok_on_the_live_shape() {
1248 let v = manifest::Value::object(vec![(
1251 "hooks",
1252 manifest::Value::object(vec![(
1253 "PostToolUse",
1254 manifest::Value::array(vec![
1255 manifest::Value::object(vec![
1256 ("matcher", manifest::Value::string("Read")),
1257 (
1258 "hooks",
1259 manifest::Value::array(vec![manifest::Value::object(vec![
1260 ("type", manifest::Value::string("command")),
1261 (
1262 "command",
1263 manifest::Value::string("node scout-read-hook.js"),
1264 ),
1265 ])]),
1266 ),
1267 ]),
1268 manifest::Value::object(vec![("matcher", manifest::Value::string("Bash"))]),
1269 ]),
1270 )]),
1271 )]);
1272 assert!(hooks_shape_ok(&v));
1273 }
1274
1275 #[test]
1276 fn hooks_shape_rejects_hooks_as_non_object() {
1277 let v = manifest::Value::object(vec![("hooks", manifest::Value::array(vec![]))]);
1278 assert!(!hooks_shape_ok(&v));
1279 }
1280
1281 #[test]
1282 fn hooks_shape_rejects_post_tool_use_as_non_array() {
1283 let v = manifest::Value::object(vec![(
1284 "hooks",
1285 manifest::Value::object(vec![("PostToolUse", manifest::Value::string("nope"))]),
1286 )]);
1287 assert!(!hooks_shape_ok(&v));
1288 }
1289
1290 #[test]
1291 fn hooks_shape_rejects_entry_with_wrong_typed_matcher() {
1292 let entries = manifest::Value::array(vec![manifest::Value::object(vec![(
1293 "matcher",
1294 manifest::Value::number(1),
1295 )])]);
1296 let v = manifest::Value::object(vec![(
1297 "hooks",
1298 manifest::Value::object(vec![("PostToolUse", entries)]),
1299 )]);
1300 assert!(!hooks_shape_ok(&v));
1301 }
1302
1303 #[test]
1304 fn hooks_shape_rejects_entry_that_is_not_an_object() {
1305 let entry = manifest::Value::string("not an object");
1306 assert!(!entry_shape_ok(&entry));
1307 }
1308
1309 #[test]
1312 fn already_has_hook_command_matches_scout_plus_marker() {
1313 let entries = vec![manifest::Value::object(vec![(
1314 "hooks",
1315 manifest::Value::array(vec![manifest::Value::object(vec![(
1316 "command",
1317 manifest::Value::string("/abs/devscout hook read"),
1318 )])]),
1319 )])];
1320 assert!(already_has_hook_command(&entries, "hook read"));
1321 assert!(
1322 !already_has_hook_command(&entries, "hook bash"),
1323 "different marker must not match"
1324 );
1325 }
1326
1327 #[test]
1328 fn already_has_hook_command_ignores_scout_without_the_marker() {
1329 let entries = vec![manifest::Value::object(vec![(
1333 "hooks",
1334 manifest::Value::array(vec![manifest::Value::object(vec![(
1335 "command",
1336 manifest::Value::string("node /opt/x/scout/scout-read-hook.js"),
1337 )])]),
1338 )])];
1339 assert!(!already_has_hook_command(&entries, "hook read"));
1340 }
1341
1342 #[test]
1343 fn already_has_hook_command_false_on_empty_entries() {
1344 assert!(!already_has_hook_command(&[], "hook read"));
1345 }
1346
1347 #[test]
1350 fn merge_hooks_adds_both_entries_and_preserves_other_top_level_keys() {
1351 let v = manifest::Value::object(vec![
1352 ("model", manifest::Value::string("x")),
1353 ("hooks", manifest::Value::object(vec![])),
1354 ]);
1355 let (updated, added) = merge_hooks(v, "/abs/devscout");
1356 assert_eq!(added, vec!["Read", "Bash"]);
1357 assert_eq!(
1358 updated.get("model").and_then(manifest::Value::as_str),
1359 Some("x"),
1360 "unrelated top-level key preserved"
1361 );
1362 let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
1363 assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
1364 }
1365
1366 #[test]
1367 fn merge_hooks_is_idempotent_on_second_call() {
1368 let v = manifest::Value::object(vec![("hooks", manifest::Value::object(vec![]))]);
1369 let (once, added1) = merge_hooks(v, "/abs/devscout");
1370 assert_eq!(added1, vec!["Read", "Bash"]);
1371 let (twice, added2) = merge_hooks(once.clone(), "/abs/devscout");
1372 assert!(
1373 added2.is_empty(),
1374 "second merge over its own output must add nothing"
1375 );
1376 assert_eq!(once, twice, "no-op merge must not alter the value at all");
1377 }
1378
1379 #[test]
1380 fn merge_hooks_preserves_existing_post_tool_use_entries() {
1381 let existing = manifest::Value::array(vec![manifest::Value::object(vec![(
1382 "matcher",
1383 manifest::Value::string("Write"),
1384 )])]);
1385 let v = manifest::Value::object(vec![(
1386 "hooks",
1387 manifest::Value::object(vec![("PostToolUse", existing)]),
1388 )]);
1389 let (updated, added) = merge_hooks(v, "/abs/devscout");
1390 assert_eq!(added, vec!["Read", "Bash"]);
1391 let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
1392 assert!(
1393 matches!(ptu, manifest::Value::Array(items) if items.len() == 3),
1394 "1 existing + 2 new"
1395 );
1396 if let manifest::Value::Array(items) = ptu {
1397 assert_eq!(
1398 items[0].get("matcher").and_then(manifest::Value::as_str),
1399 Some("Write"),
1400 "existing entry stays first, untouched"
1401 );
1402 }
1403 }
1404
1405 #[test]
1408 fn hook_snippet_is_valid_json_containing_both_subcommands() {
1409 let snippet = hook_snippet("/abs/devscout");
1410 let parsed: manifest::Value =
1411 serde_json::from_str(&snippet).expect("snippet must be valid JSON");
1412 let ptu = parsed.get("hooks").unwrap().get("PostToolUse").unwrap();
1413 assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
1414 assert!(snippet.contains("/abs/devscout hook read"));
1415 assert!(snippet.contains("/abs/devscout hook bash"));
1416 }
1417
1418 #[test]
1421 fn now_stamp_is_well_formed() {
1422 let s = now_stamp();
1423 assert_eq!(s.len(), 8 + 1 + 6 + 1 + 9);
1425 assert!(
1426 s.chars().enumerate().all(|(i, c)| if i == 8 || i == 15 {
1427 c == '-'
1428 } else {
1429 c.is_ascii_digit()
1430 }),
1431 "got {s:?}"
1432 );
1433 }
1434
1435 #[test]
1436 fn backup_settings_writes_original_bytes_verbatim() {
1437 let dir = temp_dir("backup");
1438 let path = dir.join("settings.json");
1439 let original = b"{\"a\":1}";
1440 let backup = backup_settings(&path, original).unwrap();
1441 assert!(backup
1442 .file_name()
1443 .unwrap()
1444 .to_string_lossy()
1445 .starts_with("settings.json.bak."));
1446 assert_eq!(fs::read(&backup).unwrap(), original);
1447 }
1448
1449 #[test]
1450 fn write_settings_then_read_round_trips_and_ends_with_newline() {
1451 let dir = temp_dir("write-settings");
1452 let path = dir.join("settings.json");
1453 let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
1454 write_settings(&path, &v).unwrap();
1455 let body = fs::read_to_string(&path).unwrap();
1456 assert!(body.ends_with('\n'));
1457 let reread: manifest::Value = serde_json::from_str(&body).unwrap();
1458 assert_eq!(reread, v);
1459 }
1460
1461 #[test]
1464 fn census_language_tiers_are_explicit() {
1465 assert_eq!(FULLY_SUPPORTED_EXT, &[".cs"]);
1466 assert_eq!(INDEXED_AND_GRAPHED_EXT, &[".ts", ".tsx", ".js", ".jsx"]);
1467 }
1468}