1use std::env;
10use std::fmt;
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14pub(crate) fn resolve_path(path: &Path) -> PathBuf {
24 let base = if path.is_absolute() {
25 path.to_path_buf()
26 } else {
27 env::current_dir()
31 .unwrap_or_else(|_| PathBuf::from("/"))
32 .join(path)
33 };
34 normalize(&base)
35}
36
37fn normalize(path: &Path) -> PathBuf {
41 let mut out = PathBuf::new();
42 for component in path.components() {
43 match component {
44 Component::CurDir => {}
45 Component::ParentDir => {
46 if matches!(out.components().next_back(), Some(Component::Normal(_))) {
47 out.pop();
48 }
49 }
52 other => out.push(other.as_os_str()),
53 }
54 }
55 out
56}
57
58pub fn resolve_from(base: &Path, path: &Path) -> PathBuf {
64 let joined = if path.is_absolute() {
65 path.to_path_buf()
66 } else {
67 base.join(path)
68 };
69 normalize(&joined)
70}
71
72fn dirname(p: &Path) -> PathBuf {
77 p.parent()
78 .map(Path::to_path_buf)
79 .unwrap_or_else(|| p.to_path_buf())
80}
81
82fn climb(dir: &Path, marker: &str) -> Option<PathBuf> {
92 let mut current = dir.to_path_buf();
93 loop {
94 if current.join(marker).exists() {
95 return Some(current);
96 }
97 let parent = dirname(¤t);
98 if parent == current {
99 return None;
100 }
101 current = parent;
102 }
103}
104
105fn start_dir(start_path: &Path) -> PathBuf {
110 let abs = resolve_path(start_path);
111 match fs::metadata(&abs) {
112 Ok(meta) if meta.is_dir() => abs,
113 _ => dirname(&abs),
114 }
115}
116
117pub fn find_scout_root(start_path: &Path) -> Option<PathBuf> {
119 climb(&start_dir(start_path), ".scout")
120}
121
122pub fn find_repo_root(start_path: &Path) -> Option<PathBuf> {
128 climb(&start_dir(start_path), ".git")
129}
130
131pub fn scout_dir(root: &Path) -> PathBuf {
133 root.join(".scout")
134}
135
136pub fn rel_path(root: &Path, abs_path: &Path) -> String {
144 let root_s = resolve_path(root).to_string_lossy().into_owned();
145 let abs_s = resolve_path(abs_path).to_string_lossy().into_owned();
146 let start = root_s.len() + 1;
147 let sliced = abs_s.get(start..).unwrap_or("");
148 sliced
149 .split(std::path::MAIN_SEPARATOR)
150 .collect::<Vec<_>>()
151 .join("/")
152}
153
154pub fn git_dir_for(root: &Path) -> Option<PathBuf> {
166 let dot_git = root.join(".git");
167 let meta = fs::metadata(&dot_git).ok()?;
168 if meta.is_dir() {
169 return Some(resolve_path(&dot_git));
170 }
171 let contents = fs::read_to_string(&dot_git).ok()?;
172 let line = contents
173 .split('\n')
174 .find(|l| l.trim().starts_with("gitdir:"))?;
175 let target = line.trim()["gitdir:".len()..].trim();
176 if target.is_empty() {
177 return None;
178 }
179 Some(resolve_path(&root.join(target)))
180}
181
182pub fn git_common_dir(root: &Path) -> Option<PathBuf> {
188 let git_dir = git_dir_for(root)?;
189 let common_file = git_dir.join("commondir");
190 if !common_file.exists() {
191 return Some(git_dir);
192 }
193 let target = fs::read_to_string(&common_file).ok()?;
194 let target = target.trim();
195 if target.is_empty() {
196 return Some(git_dir);
197 }
198 Some(resolve_path(&git_dir.join(target)))
199}
200
201#[allow(dead_code)]
211mod json {
212 #[derive(Debug, Clone, PartialEq)]
213 pub enum Value {
214 Null,
215 Bool(bool),
216 Number(f64),
217 String(String),
218 Array(Vec<Value>),
219 Object(Vec<(String, Value)>),
220 }
221
222 pub fn parse(input: &str) -> Result<Value, String> {
223 let chars: Vec<char> = input.chars().collect();
224 let mut pos = 0usize;
225 skip_ws(&chars, &mut pos);
226 let value = parse_value(&chars, &mut pos)?;
227 skip_ws(&chars, &mut pos);
228 if pos != chars.len() {
229 return Err(format!("unexpected trailing data at position {pos}"));
230 }
231 Ok(value)
232 }
233
234 fn skip_ws(chars: &[char], pos: &mut usize) {
235 while matches!(chars.get(*pos), Some(' ' | '\t' | '\n' | '\r')) {
236 *pos += 1;
237 }
238 }
239
240 fn peek(chars: &[char], pos: usize) -> Result<char, String> {
241 chars
242 .get(pos)
243 .copied()
244 .ok_or_else(|| "unexpected end of input".to_string())
245 }
246
247 fn parse_value(chars: &[char], pos: &mut usize) -> Result<Value, String> {
248 skip_ws(chars, pos);
249 match peek(chars, *pos)? {
250 '{' => parse_object(chars, pos),
251 '[' => parse_array(chars, pos),
252 '"' => parse_string(chars, pos).map(Value::String),
253 't' => parse_literal(chars, pos, "true", Value::Bool(true)),
254 'f' => parse_literal(chars, pos, "false", Value::Bool(false)),
255 'n' => parse_literal(chars, pos, "null", Value::Null),
256 c if c == '-' || c.is_ascii_digit() => parse_number(chars, pos),
257 c => Err(format!("unexpected character '{c}' at {}", *pos)),
258 }
259 }
260
261 fn parse_literal(
262 chars: &[char],
263 pos: &mut usize,
264 lit: &str,
265 value: Value,
266 ) -> Result<Value, String> {
267 for expected in lit.chars() {
268 let c = peek(chars, *pos)?;
269 if c != expected {
270 return Err(format!("expected literal '{lit}' at {}", *pos));
271 }
272 *pos += 1;
273 }
274 Ok(value)
275 }
276
277 fn parse_object(chars: &[char], pos: &mut usize) -> Result<Value, String> {
278 *pos += 1; let mut entries = Vec::new();
280 skip_ws(chars, pos);
281 if peek(chars, *pos)? == '}' {
282 *pos += 1;
283 return Ok(Value::Object(entries));
284 }
285 loop {
286 skip_ws(chars, pos);
287 if peek(chars, *pos)? != '"' {
288 return Err(format!("expected string key at {}", *pos));
289 }
290 let key = parse_string(chars, pos)?;
291 skip_ws(chars, pos);
292 if peek(chars, *pos)? != ':' {
293 return Err(format!("expected ':' at {}", *pos));
294 }
295 *pos += 1;
296 let value = parse_value(chars, pos)?;
297 entries.push((key, value));
298 skip_ws(chars, pos);
299 match peek(chars, *pos)? {
300 ',' => *pos += 1,
301 '}' => {
302 *pos += 1;
303 break;
304 }
305 c => return Err(format!("expected ',' or '}}' at {} (got '{c}')", *pos)),
306 }
307 }
308 Ok(Value::Object(entries))
309 }
310
311 fn parse_array(chars: &[char], pos: &mut usize) -> Result<Value, String> {
312 *pos += 1; let mut items = Vec::new();
314 skip_ws(chars, pos);
315 if peek(chars, *pos)? == ']' {
316 *pos += 1;
317 return Ok(Value::Array(items));
318 }
319 loop {
320 let value = parse_value(chars, pos)?;
321 items.push(value);
322 skip_ws(chars, pos);
323 match peek(chars, *pos)? {
324 ',' => *pos += 1,
325 ']' => {
326 *pos += 1;
327 break;
328 }
329 c => return Err(format!("expected ',' or ']' at {} (got '{c}')", *pos)),
330 }
331 }
332 Ok(Value::Array(items))
333 }
334
335 fn parse_string(chars: &[char], pos: &mut usize) -> Result<String, String> {
336 *pos += 1; let mut out = String::new();
338 loop {
339 let c = peek(chars, *pos)?;
340 *pos += 1;
341 match c {
342 '"' => break,
343 '\\' => {
344 let esc = peek(chars, *pos)?;
345 *pos += 1;
346 match esc {
347 '"' => out.push('"'),
348 '\\' => out.push('\\'),
349 '/' => out.push('/'),
350 'b' => out.push('\u{8}'),
351 'f' => out.push('\u{c}'),
352 'n' => out.push('\n'),
353 'r' => out.push('\r'),
354 't' => out.push('\t'),
355 'u' => {
356 let cp = parse_hex4(chars, pos)?;
357 if (0xD800..=0xDBFF).contains(&cp) {
358 if peek(chars, *pos)? == '\\' && chars.get(*pos + 1) == Some(&'u') {
359 *pos += 2;
360 let low = parse_hex4(chars, pos)?;
361 if (0xDC00..=0xDFFF).contains(&low) {
362 let c32 = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
363 out.push(
364 char::from_u32(c32).ok_or("invalid surrogate pair")?,
365 );
366 } else {
367 return Err("invalid low surrogate".to_string());
368 }
369 } else {
370 return Err("unpaired high surrogate".to_string());
371 }
372 } else {
373 out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
374 }
375 }
376 other => return Err(format!("invalid escape '\\{other}'")),
377 }
378 }
379 c if (c as u32) < 0x20 => return Err("control character in string".to_string()),
380 c => out.push(c),
381 }
382 }
383 Ok(out)
384 }
385
386 fn parse_hex4(chars: &[char], pos: &mut usize) -> Result<u32, String> {
387 let mut value = 0u32;
388 for _ in 0..4 {
389 let c = peek(chars, *pos)?;
390 let digit = c
391 .to_digit(16)
392 .ok_or_else(|| format!("invalid hex digit '{c}'"))?;
393 value = value * 16 + digit;
394 *pos += 1;
395 }
396 Ok(value)
397 }
398
399 fn parse_number(chars: &[char], pos: &mut usize) -> Result<Value, String> {
400 let start = *pos;
401 if peek(chars, *pos)? == '-' {
402 *pos += 1;
403 }
404 while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
405 *pos += 1;
406 }
407 if matches!(chars.get(*pos), Some('.')) {
408 *pos += 1;
409 while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
410 *pos += 1;
411 }
412 }
413 if matches!(chars.get(*pos), Some('e' | 'E')) {
414 *pos += 1;
415 if matches!(chars.get(*pos), Some('+' | '-')) {
416 *pos += 1;
417 }
418 while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
419 *pos += 1;
420 }
421 }
422 let text: String = chars[start..*pos].iter().collect();
423 text.parse::<f64>()
424 .map(Value::Number)
425 .map_err(|e| format!("invalid number '{text}': {e}"))
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Default)]
439pub struct RegistryEntry {
440 pub root: String,
442 pub kind: String,
444 pub label: Option<String>,
446 pub scope: Vec<String>,
448 pub initialized: String,
450 pub last_seen: String,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Default)]
458pub struct Registry {
459 pub roots: Vec<RegistryEntry>,
461}
462
463#[derive(Debug)]
466pub enum RegistryError {
467 NotValidJson {
471 path: PathBuf,
473 detail: String,
475 },
476 NoRootsArray {
478 path: PathBuf,
480 },
481}
482
483impl fmt::Display for RegistryError {
484 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485 match self {
486 RegistryError::NotValidJson { path, detail } => {
487 write!(
488 f,
489 "registry at {} is not valid JSON: {detail}",
490 path.display()
491 )
492 }
493 RegistryError::NoRootsArray { path } => {
494 write!(f, "registry at {} has no \"roots\" array", path.display())
495 }
496 }
497 }
498}
499
500impl std::error::Error for RegistryError {}
501
502pub fn registry_path() -> PathBuf {
511 match env::var("SCOUT_REGISTRY") {
512 Ok(v) => PathBuf::from(v),
513 Err(_) => default_registry_path(),
514 }
515}
516
517fn default_registry_path() -> PathBuf {
529 match env::var("HOME") {
530 Ok(home) => PathBuf::from(home)
531 .join(".claude")
532 .join("scout")
533 .join("repos.json"),
534 Err(_) => PathBuf::from("repos.json"),
535 }
536}
537
538pub fn read_registry() -> Result<Registry, RegistryError> {
543 let path = registry_path();
544 if !path.exists() {
545 return Ok(Registry::default());
546 }
547 let text = match fs::read_to_string(&path) {
548 Ok(t) => t,
549 Err(e) => {
550 return Err(RegistryError::NotValidJson {
551 path,
552 detail: e.to_string(),
553 })
554 }
555 };
556 let value = json::parse(&text).map_err(|detail| RegistryError::NotValidJson {
557 path: path.clone(),
558 detail,
559 })?;
560 let roots_value = match &value {
561 json::Value::Object(fields) => fields.iter().find(|(k, _)| k == "roots").map(|(_, v)| v),
562 _ => None,
563 };
564 match roots_value {
565 Some(json::Value::Array(items)) => Ok(Registry {
566 roots: items.iter().map(entry_from_json).collect(),
567 }),
568 _ => Err(RegistryError::NoRootsArray { path }),
569 }
570}
571
572pub fn entry_for(root: &Path) -> Result<Option<RegistryEntry>, RegistryError> {
576 let abs = resolve_path(root).to_string_lossy().into_owned();
577 let registry = read_registry()?;
578 Ok(registry.roots.into_iter().find(|entry| entry.root == abs))
579}
580
581fn entry_from_json(value: &json::Value) -> RegistryEntry {
582 let fields: &[(String, json::Value)] = match value {
583 json::Value::Object(f) => f.as_slice(),
584 _ => &[],
585 };
586 let find = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v);
587 let as_string = |v: Option<&json::Value>| match v {
588 Some(json::Value::String(s)) => Some(s.clone()),
589 _ => None,
590 };
591 let scope = match find("scope") {
592 Some(json::Value::Array(items)) => items
593 .iter()
594 .filter_map(|v| match v {
595 json::Value::String(s) => Some(s.clone()),
596 _ => None,
597 })
598 .collect(),
599 _ => Vec::new(),
600 };
601 RegistryEntry {
602 root: as_string(find("root")).unwrap_or_default(),
603 kind: as_string(find("kind")).unwrap_or_default(),
604 label: as_string(find("label")),
605 scope,
606 initialized: as_string(find("initialized")).unwrap_or_default(),
607 last_seen: as_string(find("last_seen")).unwrap_or_default(),
608 }
609}
610
611#[cfg(test)]
620mod tests {
621 use super::*;
622 use std::fs as test_fs;
623 use std::sync::atomic::{AtomicU64, Ordering};
624
625 static COUNTER: AtomicU64 = AtomicU64::new(0);
626
627 fn unique_temp_dir(prefix: &str) -> PathBuf {
628 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
629 let dir =
630 env::temp_dir().join(format!("scout-repo-rs-{prefix}-{}-{n}", std::process::id()));
631 test_fs::create_dir_all(&dir).expect("create temp dir");
632 dir
633 }
634
635 #[test]
638 fn resolve_path_collapses_dotdot_past_root() {
639 assert_eq!(
640 resolve_path(Path::new("/a/b/../../../c")),
641 PathBuf::from("/c")
642 );
643 }
644
645 #[test]
646 fn resolve_path_is_idempotent_on_absolute_input() {
647 let p = Path::new("/already/absolute/path");
648 assert_eq!(resolve_path(p), PathBuf::from(p));
649 }
650
651 #[test]
652 fn dirname_of_root_is_root() {
653 assert_eq!(dirname(Path::new("/")), PathBuf::from("/"));
654 }
655
656 #[test]
659 fn climb_finds_marker_in_ancestor() {
660 let root = unique_temp_dir("climb-hit");
661 test_fs::create_dir_all(root.join(".scout")).unwrap();
662 test_fs::create_dir_all(root.join("src/deep")).unwrap();
663 assert_eq!(climb(&root.join("src/deep"), ".scout"), Some(root));
664 }
665
666 #[test]
667 fn climb_returns_none_without_a_match() {
668 let root = unique_temp_dir("climb-miss");
669 test_fs::create_dir_all(root.join("src/deep")).unwrap();
670 assert_eq!(
671 climb(&root.join("src/deep"), ".nonexistent-marker-xyz"),
672 None
673 );
674 }
675
676 #[test]
679 fn rel_path_joins_with_forward_slash() {
680 let root = unique_temp_dir("relpath");
681 let abs = root.join("src").join("deep").join("f.ts");
682 assert_eq!(rel_path(&root, &abs), "src/deep/f.ts");
683 }
684
685 #[test]
686 fn rel_path_on_a_path_not_under_root_does_not_panic() {
687 let root = unique_temp_dir("relpath-mismatch-a");
688 let other = unique_temp_dir("relpath-mismatch-b");
689 let _ = rel_path(&root, &other);
691 }
692
693 #[test]
696 fn git_dir_for_none_without_dot_git() {
697 let root = unique_temp_dir("nogit");
698 assert_eq!(git_dir_for(&root), None);
699 assert_eq!(git_common_dir(&root), None);
700 }
701
702 #[test]
703 fn git_dir_for_a_plain_directory_dot_git() {
704 let root = unique_temp_dir("plaingit");
705 test_fs::create_dir_all(root.join(".git")).unwrap();
706 assert_eq!(git_dir_for(&root), Some(root.join(".git")));
707 assert_eq!(git_common_dir(&root), Some(root.join(".git")));
708 }
709
710 #[test]
711 fn git_dir_for_parses_gitdir_pointer_file() {
712 let root = unique_temp_dir("ptrgit");
713 let target = unique_temp_dir("ptrgit-target");
714 test_fs::write(root.join(".git"), format!("gitdir: {}\n", target.display())).unwrap();
715 assert_eq!(git_dir_for(&root), Some(target));
716 }
717
718 #[test]
719 fn git_common_dir_follows_commondir_file() {
720 let root = unique_temp_dir("commondir");
721 let admin = root.join(".git-admin");
722 test_fs::create_dir_all(&admin).unwrap();
723 test_fs::write(root.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
724 let shared = unique_temp_dir("commondir-shared");
725 test_fs::write(admin.join("commondir"), format!("{}\n", shared.display())).unwrap();
726 assert_eq!(git_common_dir(&root), Some(shared));
727 }
728
729 #[test]
732 fn json_parses_object_array_string_number_bool_null() {
733 let v = json::parse(r#"{"a": [1, -2.5, "s", true, false, null]}"#).unwrap();
734 match v {
735 json::Value::Object(fields) => {
736 assert_eq!(fields.len(), 1);
737 assert_eq!(fields[0].0, "a");
738 match &fields[0].1 {
739 json::Value::Array(items) => {
740 assert_eq!(items.len(), 6);
741 assert_eq!(items[0], json::Value::Number(1.0));
742 assert_eq!(items[1], json::Value::Number(-2.5));
743 assert_eq!(items[2], json::Value::String("s".to_string()));
744 assert_eq!(items[3], json::Value::Bool(true));
745 assert_eq!(items[4], json::Value::Bool(false));
746 assert_eq!(items[5], json::Value::Null);
747 }
748 other => panic!("expected array, got {other:?}"),
749 }
750 }
751 other => panic!("expected object, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn json_parses_unicode_escape_and_surrogate_pair() {
757 let v = json::parse(r#""café 😀""#).unwrap();
760 assert_eq!(v, json::Value::String("caf\u{e9} \u{1F600}".to_string()));
761 }
762
763 #[test]
764 fn json_rejects_malformed_input() {
765 assert!(json::parse("{ not json").is_err());
766 assert!(json::parse("").is_err());
767 assert!(json::parse("{\"a\": }").is_err());
768 }
769
770 #[test]
771 fn entry_from_json_defaults_missing_fields() {
772 let v = json::parse(r#"{"root": "/x", "kind": "git"}"#).unwrap();
773 let entry = entry_from_json(&v);
774 assert_eq!(
775 entry,
776 RegistryEntry {
777 root: "/x".to_string(),
778 kind: "git".to_string(),
779 label: None,
780 scope: vec![],
781 initialized: String::new(),
782 last_seen: String::new(),
783 }
784 );
785 }
786
787 #[test]
788 fn entry_from_json_reads_a_full_entry() {
789 let v = json::parse(
790 r#"{"root": "/x", "kind": "git", "label": "lbl", "scope": ["a", "b"], "initialized": "2026-01-01", "last_seen": "2026-01-02"}"#,
791 )
792 .unwrap();
793 let entry = entry_from_json(&v);
794 assert_eq!(entry.root, "/x");
795 assert_eq!(entry.label, Some("lbl".to_string()));
796 assert_eq!(entry.scope, vec!["a".to_string(), "b".to_string()]);
797 assert_eq!(entry.initialized, "2026-01-01");
798 assert_eq!(entry.last_seen, "2026-01-02");
799 }
800}