1use std::collections::{HashMap, HashSet};
17use std::fmt;
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
25use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer};
26
27use crate::repo::{entry_for, git_common_dir, scout_dir, RegistryEntry, RegistryError};
28
29#[derive(Debug, Clone, PartialEq)]
40pub enum Value {
42 Null,
44 Bool(bool),
46 Number(serde_json::Number),
48 String(String),
50 Array(Vec<Value>),
52 Object(Vec<(String, Value)>),
54}
55
56impl Value {
57 pub fn object(entries: Vec<(&str, Value)>) -> Value {
59 Value::Object(
60 entries
61 .into_iter()
62 .map(|(k, v)| (k.to_string(), v))
63 .collect(),
64 )
65 }
66
67 pub fn string(s: impl Into<String>) -> Value {
69 Value::String(s.into())
70 }
71
72 pub fn array(items: Vec<Value>) -> Value {
74 Value::Array(items)
75 }
76
77 pub fn number(n: impl Into<serde_json::Number>) -> Value {
79 Value::Number(n.into())
80 }
81
82 pub fn get(&self, key: &str) -> Option<&Value> {
85 self.as_object()?
86 .iter()
87 .find(|(k, _)| k == key)
88 .map(|(_, v)| v)
89 }
90
91 pub fn as_object(&self) -> Option<&[(String, Value)]> {
93 match self {
94 Value::Object(entries) => Some(entries.as_slice()),
95 _ => None,
96 }
97 }
98
99 pub fn as_str(&self) -> Option<&str> {
105 match self {
106 Value::String(s) => Some(s.as_str()),
107 _ => None,
108 }
109 }
110}
111
112impl<'de> Deserialize<'de> for Value {
113 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114 where
115 D: Deserializer<'de>,
116 {
117 struct ValueVisitor;
118
119 impl<'de> Visitor<'de> for ValueVisitor {
120 type Value = Value;
121
122 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 f.write_str("a JSON value")
124 }
125
126 fn visit_bool<E>(self, v: bool) -> Result<Value, E> {
127 Ok(Value::Bool(v))
128 }
129 fn visit_i64<E>(self, v: i64) -> Result<Value, E> {
130 Ok(Value::Number(v.into()))
131 }
132 fn visit_u64<E>(self, v: u64) -> Result<Value, E> {
133 Ok(Value::Number(v.into()))
134 }
135 fn visit_f64<E>(self, v: f64) -> Result<Value, E> {
136 Ok(Value::Number(
137 serde_json::Number::from_f64(v).unwrap_or_else(|| 0.into()),
138 ))
139 }
140 fn visit_str<E>(self, v: &str) -> Result<Value, E> {
141 Ok(Value::String(v.to_owned()))
142 }
143 fn visit_string<E>(self, v: String) -> Result<Value, E> {
144 Ok(Value::String(v))
145 }
146 fn visit_unit<E>(self) -> Result<Value, E> {
147 Ok(Value::Null)
148 }
149 fn visit_none<E>(self) -> Result<Value, E> {
150 Ok(Value::Null)
151 }
152 fn visit_some<D2>(self, deserializer: D2) -> Result<Value, D2::Error>
153 where
154 D2: Deserializer<'de>,
155 {
156 Value::deserialize(deserializer)
157 }
158 fn visit_seq<A>(self, mut seq: A) -> Result<Value, A::Error>
159 where
160 A: SeqAccess<'de>,
161 {
162 let mut items = Vec::new();
163 while let Some(item) = seq.next_element()? {
164 items.push(item);
165 }
166 Ok(Value::Array(items))
167 }
168 fn visit_map<A>(self, mut map: A) -> Result<Value, A::Error>
174 where
175 A: MapAccess<'de>,
176 {
177 let mut entries = Vec::new();
178 while let Some((k, v)) = map.next_entry::<String, Value>()? {
179 entries.push((k, v));
180 }
181 Ok(Value::Object(entries))
182 }
183 }
184
185 deserializer.deserialize_any(ValueVisitor)
186 }
187}
188
189impl Serialize for Value {
190 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
191 where
192 S: Serializer,
193 {
194 match self {
195 Value::Null => serializer.serialize_unit(),
196 Value::Bool(b) => serializer.serialize_bool(*b),
197 Value::Number(n) => n.serialize(serializer),
198 Value::String(s) => serializer.serialize_str(s),
199 Value::Array(items) => {
200 let mut seq = serializer.serialize_seq(Some(items.len()))?;
201 for item in items {
202 seq.serialize_element(item)?;
203 }
204 seq.end()
205 }
206 Value::Object(entries) => {
210 let mut map = serializer.serialize_map(Some(entries.len()))?;
211 for (k, v) in entries {
212 map.serialize_entry(k, v)?;
213 }
214 map.end()
215 }
216 }
217 }
218}
219
220fn manifest_path(root: &Path) -> PathBuf {
228 match git_common_dir(root) {
229 Some(common) => common.join("scout").join("manifest.json"),
230 None => legacy_manifest_path(root),
231 }
232}
233
234pub fn legacy_manifest_path(root: &Path) -> PathBuf {
237 scout_dir(root).join("manifest.json")
238}
239
240#[derive(Debug)]
248pub enum ManifestError {
250 Io {
254 path: PathBuf,
256 detail: String,
258 },
259 InvalidJson {
261 path: PathBuf,
263 detail: String,
265 },
266}
267
268impl fmt::Display for ManifestError {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 match self {
271 ManifestError::Io { path, detail } => {
272 write!(f, "failed to read manifest at {}: {detail}", path.display())
273 }
274 ManifestError::InvalidJson { path, detail } => {
275 write!(
276 f,
277 "manifest at {} is not valid JSON: {detail}",
278 path.display()
279 )
280 }
281 }
282 }
283}
284
285impl std::error::Error for ManifestError {}
286
287fn read_json_value(path: &Path) -> Result<Value, ManifestError> {
288 let text = fs::read_to_string(path).map_err(|e| ManifestError::Io {
289 path: path.to_path_buf(),
290 detail: e.to_string(),
291 })?;
292 serde_json::from_str::<Value>(&text).map_err(|e| ManifestError::InvalidJson {
293 path: path.to_path_buf(),
294 detail: e.to_string(),
295 })
296}
297
298pub fn read_manifest(root: &Path) -> Result<Option<Value>, ManifestError> {
302 let path = manifest_path(root);
303 if path.exists() {
304 return read_json_value(&path).map(Some);
305 }
306 let legacy = legacy_manifest_path(root);
307 if legacy.exists() {
308 return read_json_value(&legacy).map(Some);
309 }
310 Ok(None)
311}
312
313pub fn write_manifest(root: &Path, value: &Value) -> io::Result<()> {
318 let path = manifest_path(root);
319 if let Some(dir) = path.parent() {
320 fs::create_dir_all(dir)?;
321 }
322 let json = serde_json::to_string_pretty(value)
323 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
324
325 let suffix = SystemTime::now()
326 .duration_since(UNIX_EPOCH)
327 .map(|d| d.as_nanos())
328 .unwrap_or(0);
329 let tmp_name = format!(
330 "{}.tmp.{}.{suffix}",
331 path.file_name()
332 .and_then(|n| n.to_str())
333 .unwrap_or("manifest.json"),
334 std::process::id(),
335 );
336 let tmp_path = path.with_file_name(tmp_name);
337
338 if let Err(e) = fs::write(&tmp_path, json.as_bytes()) {
339 let _ = fs::remove_file(&tmp_path);
340 return Err(e);
341 }
342 if let Err(e) = fs::rename(&tmp_path, &path) {
343 let _ = fs::remove_file(&tmp_path);
344 return Err(e);
345 }
346 Ok(())
347}
348
349#[derive(Debug, Clone, PartialEq)]
354pub struct FindHit {
356 pub path: String,
358 pub purpose: Option<String>,
363 pub source: String,
367}
368
369#[derive(Debug)]
370pub enum FindError {
372 Manifest(ManifestError),
374 EntriesNotObject,
380}
381
382impl From<ManifestError> for FindError {
383 fn from(e: ManifestError) -> Self {
384 FindError::Manifest(e)
385 }
386}
387
388impl fmt::Display for FindError {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 match self {
391 FindError::Manifest(e) => write!(f, "{e}"),
392 FindError::EntriesNotObject => write!(f, "manifest has no \"entries\" object"),
393 }
394 }
395}
396
397impl std::error::Error for FindError {}
398
399struct Scored {
400 path: String,
401 purpose: Option<String>,
402 source: String,
403 hits: usize,
404 inbound: usize,
409}
410
411pub fn find_in_manifest(root: &Path, query: &str) -> Result<Vec<FindHit>, FindError> {
418 Ok(find_in_manifest_detailed(root, query, &HashMap::new())?.hits)
419}
420
421pub struct FindResult {
424 pub hits: Vec<FindHit>,
426 pub fallback: bool,
428}
429
430pub fn find_in_manifest_detailed(
456 root: &Path,
457 query: &str,
458 inbound: &HashMap<String, usize>,
459) -> Result<FindResult, FindError> {
460 let manifest = match read_manifest(root)? {
461 Some(m) => m,
462 None => {
463 return Ok(FindResult {
464 hits: Vec::new(),
465 fallback: false,
466 })
467 }
468 };
469
470 let tokens: Vec<String> = query
471 .to_lowercase()
472 .split_whitespace()
473 .map(String::from)
474 .collect();
475 if tokens.is_empty() {
476 return Ok(FindResult {
477 hits: Vec::new(),
478 fallback: false,
479 });
480 }
481
482 let entries = manifest
483 .get("entries")
484 .and_then(Value::as_object)
485 .ok_or(FindError::EntriesNotObject)?;
486
487 let scored: Vec<Scored> = entries
488 .iter()
489 .map(|(path, e)| {
490 let purpose = e.get("purpose").and_then(Value::as_str).map(String::from);
491 let source = e
492 .get("source")
493 .and_then(Value::as_str)
494 .map(String::from)
495 .unwrap_or_else(|| "heuristic".to_string());
496 let hay = format!("{path} {}", purpose.as_deref().unwrap_or("")).to_lowercase();
497 let hits = tokens.iter().filter(|t| hay.contains(t.as_str())).count();
498 Scored {
499 path: path.clone(),
500 purpose,
501 source,
502 hits,
503 inbound: inbound.get(path).copied().unwrap_or(0),
504 }
505 })
506 .collect();
507
508 let full: Vec<&Scored> = scored.iter().filter(|s| s.hits == tokens.len()).collect();
509 let fallback = full.is_empty();
510 let mut pool: Vec<&Scored> = if !full.is_empty() {
511 full
512 } else {
513 scored.iter().filter(|s| s.hits > 0).collect()
514 };
515 pool.sort_by(|a, b| b.hits.cmp(&a.hits).then_with(|| b.inbound.cmp(&a.inbound)));
518
519 let hits = pool
520 .into_iter()
521 .map(|s| FindHit {
522 path: s.path.clone(),
523 purpose: s.purpose.clone(),
524 source: s.source.clone(),
525 })
526 .collect();
527 Ok(FindResult { hits, fallback })
528}
529
530pub fn git_head(root: &Path) -> Option<String> {
539 let output = Command::new("git")
540 .arg("-C")
541 .arg(root)
542 .args(["rev-parse", "HEAD"])
543 .output()
544 .ok()?;
545 if !output.status.success() {
546 return None;
547 }
548 let stdout = String::from_utf8(output.stdout).ok()?;
549 Some(stdout.trim().to_string())
550}
551
552pub fn scope_for(
560 root: &Path,
561 explicit_dirs: Option<&[String]>,
562) -> Result<Vec<String>, RegistryError> {
563 if let Some(dirs) = explicit_dirs {
564 if !dirs.is_empty() {
565 return Ok(dirs.to_vec());
566 }
567 }
568 let entry: Option<RegistryEntry> = entry_for(root)?;
569 if let Some(e) = entry {
570 if !e.scope.is_empty() {
571 return Ok(e.scope);
572 }
573 }
574 Ok(vec![".".to_string()])
575}
576
577fn index_state_dir(root: &Path) -> PathBuf {
586 match git_common_dir(root) {
587 Some(common) => common.join("scout"),
588 None => scout_dir(root),
589 }
590}
591
592pub fn index_state_path(root: &Path) -> PathBuf {
594 index_state_dir(root).join("index-state.json")
595}
596
597pub fn write_index_state(
606 root: &Path,
607 head: Option<String>,
608 dirty: bool,
609 dirty_indexed_files: &[String],
610 file_count: i64,
611) -> io::Result<()> {
612 let path = index_state_path(root);
613 if let Some(dir) = path.parent() {
614 fs::create_dir_all(dir)?;
615 }
616 let mut sorted = dirty_indexed_files.to_vec();
617 sorted.sort();
618 let value = Value::object(vec![
619 ("head", head.map(Value::string).unwrap_or(Value::Null)),
620 ("dirty", Value::Bool(dirty)),
621 ("indexed_at", Value::string(crate::hookio::iso8601_now())),
622 ("file_count", Value::number(file_count)),
623 (
624 "dirty_indexed_files",
625 Value::array(sorted.into_iter().map(Value::string).collect()),
626 ),
627 ]);
628 let json = serde_json::to_string_pretty(&value)
629 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
630 fs::write(&path, format!("{json}\n"))
631}
632
633pub fn read_index_state(root: &Path) -> Option<Value> {
637 let text = fs::read_to_string(index_state_path(root)).ok()?;
638 serde_json::from_str::<Value>(&text).ok()
639}
640
641const STATUS_LINE_CAP: usize = 5000;
643
644fn git_status_paths(root: &Path, scope: &[String]) -> Option<HashSet<String>> {
654 let mut cmd = Command::new("git");
655 cmd.arg("-C")
656 .arg(root)
657 .args(["status", "--porcelain", "--untracked-files=all", "--"]);
658 for s in scope {
659 cmd.arg(s);
660 }
661 let output = cmd.output().ok()?;
662 if !output.status.success() {
663 return None;
664 }
665 let text = String::from_utf8(output.stdout).ok()?;
666 let mut paths = HashSet::new();
667 for line in text
668 .split('\n')
669 .filter(|l| !l.is_empty())
670 .take(STATUS_LINE_CAP)
671 {
672 if line.len() < 3 {
673 continue;
674 }
675 for part in line[3..].split(" -> ") {
676 paths.insert(part.to_string());
677 }
678 }
679 Some(paths)
680}
681
682pub fn is_working_tree_dirty(root: &Path, scope: &[String]) -> bool {
686 git_status_paths(root, scope)
687 .map(|p| !p.is_empty())
688 .unwrap_or(false)
689}
690
691pub fn dirty_indexed_files_at(
696 root: &Path,
697 scope: &[String],
698 indexed_files: &HashSet<String>,
699) -> Vec<String> {
700 let Some(changed) = git_status_paths(root, scope) else {
701 return Vec::new();
702 };
703 let mut out: Vec<String> = changed
704 .into_iter()
705 .filter(|p| indexed_files.contains(p))
706 .collect();
707 out.sort();
708 out
709}
710
711pub fn freshness_warning(root: &Path) -> Option<String> {
727 let state = read_index_state(root)?;
728 let stored_head = state.get("head")?.as_str()?.to_string();
729
730 let current_head = git_head(root)?;
731
732 let manifest = read_manifest(root).ok().flatten();
733 let indexed_files: HashSet<String> = manifest
734 .as_ref()
735 .and_then(|m| m.get("entries"))
736 .and_then(|v| v.as_object())
737 .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
738 .unwrap_or_default();
739 let scope: Vec<String> = manifest
740 .as_ref()
741 .and_then(|m| m.get("scoped_dirs"))
742 .and_then(|v| match v {
743 Value::Array(items) => Some(
744 items
745 .iter()
746 .filter_map(Value::as_str)
747 .map(str::to_string)
748 .collect::<Vec<_>>(),
749 ),
750 _ => None,
751 })
752 .filter(|v: &Vec<String>| !v.is_empty())
753 .unwrap_or_else(|| vec![".".to_string()]);
754
755 let current_dirty = dirty_indexed_files_at(root, &scope, &indexed_files);
756 let baseline_dirty: HashSet<String> = state
757 .get("dirty_indexed_files")
758 .and_then(|v| match v {
759 Value::Array(items) => Some(
760 items
761 .iter()
762 .filter_map(Value::as_str)
763 .map(str::to_string)
764 .collect(),
765 ),
766 _ => None,
767 })
768 .unwrap_or_default();
769 let changed_count = current_dirty
770 .iter()
771 .filter(|p| !baseline_dirty.contains(*p))
772 .count();
773
774 if current_head == stored_head && changed_count == 0 {
775 return None;
776 }
777
778 let repo_id = root.file_name().and_then(|n| n.to_str()).unwrap_or("repo");
779 let indexed_short = &stored_head[..stored_head.len().min(7)];
780 let head_short = ¤t_head[..current_head.len().min(7)];
781 Some(format!(
782 "devscout: index for {repo_id} is stale (indexed at {indexed_short}, HEAD {head_short}; {changed_count} changed files) — rebuild with devscout map"
783 ))
784}
785
786#[cfg(test)]
794mod tests {
795 use super::*;
796 use std::env;
797 use std::sync::atomic::{AtomicU64, Ordering};
798 use std::sync::Mutex;
799
800 static COUNTER: AtomicU64 = AtomicU64::new(0);
801 static REGISTRY_ENV_LOCK: Mutex<()> = Mutex::new(());
807
808 fn unique_temp_dir(prefix: &str) -> PathBuf {
809 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
810 let dir = env::temp_dir().join(format!(
811 "scout-manifest-rs-{prefix}-{}-{n}",
812 std::process::id()
813 ));
814 fs::create_dir_all(&dir).expect("create temp dir");
815 dir
816 }
817
818 #[test]
821 fn value_round_trips_object_array_scalars_preserving_key_order() {
822 let text = r#"{"z": 1, "a": [1, -2, "s", true, false, null], "m": {"k1": 1, "k0": 2}}"#;
823 let v: Value = serde_json::from_str(text).unwrap();
824 match &v {
825 Value::Object(fields) => {
826 assert_eq!(fields[0].0, "z");
827 assert_eq!(fields[1].0, "a");
828 assert_eq!(fields[2].0, "m");
829 }
830 other => panic!("expected object, got {other:?}"),
831 }
832 let m = v.get("m").unwrap().as_object().unwrap();
834 assert_eq!(m[0].0, "k1");
835 assert_eq!(m[1].0, "k0");
836 }
837
838 #[test]
839 fn value_pretty_print_matches_json_stringify_indent_two_shape() {
840 let v = Value::object(vec![
841 ("a", Value::number(1)),
842 ("b", Value::array(vec![Value::string("x")])),
843 ]);
844 let out = serde_json::to_string_pretty(&v).unwrap();
845 assert_eq!(out, "{\n \"a\": 1,\n \"b\": [\n \"x\"\n ]\n}");
846 }
847
848 #[test]
849 fn value_empty_object_and_array_have_no_interior_whitespace() {
850 assert_eq!(
851 serde_json::to_string_pretty(&Value::object(vec![])).unwrap(),
852 "{}"
853 );
854 assert_eq!(
855 serde_json::to_string_pretty(&Value::array(vec![])).unwrap(),
856 "[]"
857 );
858 }
859
860 #[test]
861 fn value_unicode_round_trips_unescaped() {
862 let v: Value = serde_json::from_str(r#""café 😀""#).unwrap();
863 assert_eq!(v, Value::string("café 😀"));
864 assert_eq!(serde_json::to_string_pretty(&v).unwrap(), "\"café 😀\"");
866 }
867
868 #[test]
871 fn a_non_git_root_uses_the_legacy_scout_path() {
872 let root = unique_temp_dir("plain");
873 assert_eq!(manifest_path(&root), legacy_manifest_path(&root));
874 assert_eq!(
875 legacy_manifest_path(&root),
876 root.join(".scout").join("manifest.json")
877 );
878 }
879
880 #[test]
883 fn read_manifest_is_none_when_nothing_exists() {
884 let root = unique_temp_dir("missing");
885 assert!(read_manifest(&root).unwrap().is_none());
886 }
887
888 #[test]
889 fn read_manifest_errs_on_corrupt_json() {
890 let root = unique_temp_dir("corrupt");
891 fs::create_dir_all(root.join(".scout")).unwrap();
892 fs::write(root.join(".scout").join("manifest.json"), "{not valid json").unwrap();
893 assert!(matches!(
894 read_manifest(&root),
895 Err(ManifestError::InvalidJson { .. })
896 ));
897 }
898
899 #[test]
900 fn write_then_read_round_trips_on_a_plain_root() {
901 let root = unique_temp_dir("roundtrip");
902 let obj = Value::object(vec![
903 ("built_at_head", Value::string("abc")),
904 ("scoped_dirs", Value::array(vec![Value::string("src")])),
905 (
906 "entries",
907 Value::object(vec![(
908 "src/a.ts",
909 Value::object(vec![
910 ("purpose", Value::string("does A")),
911 ("mtime", Value::number(1)),
912 ]),
913 )]),
914 ),
915 ]);
916 write_manifest(&root, &obj).unwrap();
917 assert_eq!(read_manifest(&root).unwrap(), Some(obj));
918 }
919
920 #[test]
921 fn write_manifest_leaves_no_tmp_file_behind() {
922 let root = unique_temp_dir("notmp");
923 write_manifest(&root, &Value::object(vec![])).unwrap();
924 let dir = legacy_manifest_path(&root).parent().unwrap().to_path_buf();
925 let stray: Vec<_> = fs::read_dir(&dir)
926 .unwrap()
927 .filter_map(|e| e.ok())
928 .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
929 .collect();
930 assert!(stray.is_empty(), "stray tmp files: {stray:?}");
931 }
932
933 fn sample_manifest() -> Value {
936 Value::object(vec![
937 ("built_at_head", Value::string("h")),
938 ("scoped_dirs", Value::array(vec![])),
939 (
940 "entries",
941 Value::object(vec![
942 (
943 "src/GroupRepository.cs",
944 Value::object(vec![
945 ("purpose", Value::string("Mongo CRUD for groups")),
946 ("mtime", Value::number(1)),
947 ]),
948 ),
949 (
950 "src/Other.cs",
951 Value::object(vec![
952 ("purpose", Value::string("unrelated")),
953 ("mtime", Value::number(1)),
954 ]),
955 ),
956 ]),
957 ),
958 ])
959 }
960
961 #[test]
962 fn find_matches_path_or_purpose_case_insensitively() {
963 let root = unique_temp_dir("find-case");
964 write_manifest(&root, &sample_manifest()).unwrap();
965 let hits = find_in_manifest(&root, "group").unwrap();
966 assert_eq!(hits.len(), 1);
967 assert_eq!(hits[0].path, "src/GroupRepository.cs");
968 assert_eq!(find_in_manifest(&root, "MONGO").unwrap().len(), 1);
969 }
970
971 #[test]
972 fn find_returns_empty_for_zero_hits_and_missing_manifest() {
973 let root = unique_temp_dir("find-zero");
974 write_manifest(&root, &sample_manifest()).unwrap();
975 assert!(find_in_manifest(&root, "nonexistentzzz")
976 .unwrap()
977 .is_empty());
978 let missing_root = unique_temp_dir("find-missing");
979 assert!(find_in_manifest(&missing_root, "anything")
980 .unwrap()
981 .is_empty());
982 }
983
984 #[test]
985 fn find_defaults_missing_source_to_heuristic() {
986 let root = unique_temp_dir("find-source");
987 let m = Value::object(vec![(
988 "entries",
989 Value::object(vec![(
990 "a.ts",
991 Value::object(vec![("purpose", Value::string("does A"))]),
992 )]),
993 )]);
994 write_manifest(&root, &m).unwrap();
995 let hits = find_in_manifest(&root, "does").unwrap();
996 assert_eq!(hits[0].source, "heuristic");
997 }
998
999 #[test]
1000 fn find_errs_when_entries_is_missing() {
1001 let root = unique_temp_dir("find-no-entries");
1002 write_manifest(&root, &Value::object(vec![("built_at_head", Value::Null)])).unwrap();
1003 assert!(matches!(
1004 find_in_manifest(&root, "x"),
1005 Err(FindError::EntriesNotObject)
1006 ));
1007 }
1008
1009 #[test]
1010 fn find_or_pool_sorts_by_hits_descending_stably_on_ties() {
1011 let root = unique_temp_dir("find-tie");
1015 let m = Value::object(vec![(
1016 "entries",
1017 Value::object(vec![
1018 (
1019 "first.ts",
1020 Value::object(vec![("purpose", Value::string("alpha only"))]),
1021 ),
1022 (
1023 "second.ts",
1024 Value::object(vec![("purpose", Value::string("alpha beta"))]),
1025 ),
1026 (
1027 "third.ts",
1028 Value::object(vec![("purpose", Value::string("beta only"))]),
1029 ),
1030 (
1031 "fourth.ts",
1032 Value::object(vec![("purpose", Value::string("neither"))]),
1033 ),
1034 ]),
1035 )]);
1036 write_manifest(&root, &m).unwrap();
1037 let hits = find_in_manifest(&root, "alpha beta").unwrap();
1038 assert_eq!(hits.len(), 1);
1040 assert_eq!(hits[0].path, "second.ts");
1041
1042 let hits2 = find_in_manifest(&root, "beta zzz").unwrap();
1045 let paths: Vec<&str> = hits2.iter().map(|h| h.path.as_str()).collect();
1048 assert_eq!(paths, vec!["second.ts", "third.ts"]);
1049 }
1050
1051 fn find_ranked(root: &Path, query: &str, inbound: &[(&str, usize)]) -> Vec<String> {
1052 let map: HashMap<String, usize> =
1053 inbound.iter().map(|(p, n)| (p.to_string(), *n)).collect();
1054 find_in_manifest_detailed(root, query, &map)
1055 .unwrap()
1056 .hits
1057 .into_iter()
1058 .map(|h| h.path)
1059 .collect()
1060 }
1061
1062 #[test]
1063 fn find_and_pool_ranks_by_inbound_count_on_the_constant_token_key() {
1064 let root = unique_temp_dir("find-and-inbound");
1069 let m = Value::object(vec![(
1070 "entries",
1071 Value::object(vec![
1072 (
1073 "src/Island.cs",
1074 Value::object(vec![("purpose", Value::string("widget ledger store"))]),
1075 ),
1076 (
1077 "src/Hub.cs",
1078 Value::object(vec![("purpose", Value::string("widget ledger hub"))]),
1079 ),
1080 ]),
1081 )]);
1082 write_manifest(&root, &m).unwrap();
1083 let paths = find_ranked(&root, "widget ledger", &[("src/Hub.cs", 7)]);
1084 assert_eq!(paths, vec!["src/Hub.cs", "src/Island.cs"]);
1085
1086 let flipped = find_ranked(&root, "widget ledger", &[("src/Island.cs", 3)]);
1089 assert_eq!(flipped, vec!["src/Island.cs", "src/Hub.cs"]);
1090 }
1091
1092 #[test]
1093 fn find_or_pool_ranks_tokens_matched_before_inbound_count() {
1094 let root = unique_temp_dir("find-or-tokens-first");
1100 let m = Value::object(vec![(
1101 "entries",
1102 Value::object(vec![
1103 (
1104 "src/hub.cs",
1105 Value::object(vec![("purpose", Value::string("alpha only"))]),
1106 ),
1107 (
1108 "src/rich.cs",
1109 Value::object(vec![("purpose", Value::string("alpha beta"))]),
1110 ),
1111 ]),
1112 )]);
1113 write_manifest(&root, &m).unwrap();
1114 let r = find_in_manifest_detailed(
1115 &root,
1116 "alpha beta zeta",
1117 &[("src/hub.cs".to_string(), 99)].into_iter().collect(),
1118 )
1119 .unwrap();
1120 assert!(
1121 r.fallback,
1122 "no entry matches every token: the OR pool answers"
1123 );
1124 let paths: Vec<&str> = r.hits.iter().map(|h| h.path.as_str()).collect();
1125 assert_eq!(paths, vec!["src/rich.cs", "src/hub.cs"], "tokens primary");
1126
1127 let m2 = Value::object(vec![(
1129 "entries",
1130 Value::object(vec![
1131 (
1132 "src/plain.cs",
1133 Value::object(vec![("purpose", Value::string("alpha here"))]),
1134 ),
1135 (
1136 "src/popular.cs",
1137 Value::object(vec![("purpose", Value::string("alpha too"))]),
1138 ),
1139 ]),
1140 )]);
1141 write_manifest(&root, &m2).unwrap();
1142 let paths2 = find_ranked(
1143 &root,
1144 "alpha zeta",
1145 &[("src/popular.cs", 9), ("src/plain.cs", 1)],
1146 );
1147 assert_eq!(paths2, vec!["src/popular.cs", "src/plain.cs"]);
1148 }
1149
1150 #[test]
1151 fn find_full_tie_keeps_manifest_order() {
1152 let root = unique_temp_dir("find-full-tie");
1156 let m = Value::object(vec![(
1157 "entries",
1158 Value::object(vec![
1159 (
1160 "src/first.cs",
1161 Value::object(vec![("purpose", Value::string("twin widget"))]),
1162 ),
1163 (
1164 "src/second.cs",
1165 Value::object(vec![("purpose", Value::string("twin widget"))]),
1166 ),
1167 (
1168 "src/third.cs",
1169 Value::object(vec![("purpose", Value::string("twin widget"))]),
1170 ),
1171 ]),
1172 )]);
1173 write_manifest(&root, &m).unwrap();
1174 let tied = [
1175 ("src/first.cs", 4usize),
1176 ("src/second.cs", 4),
1177 ("src/third.cs", 4),
1178 ];
1179 let paths = find_ranked(&root, "twin widget", &tied);
1180 assert_eq!(paths, vec!["src/first.cs", "src/second.cs", "src/third.cs"]);
1181 }
1182
1183 #[test]
1184 fn find_with_an_empty_map_returns_todays_order() {
1185 let root = unique_temp_dir("find-empty-map");
1190
1191 let m = Value::object(vec![(
1195 "entries",
1196 Value::object(vec![
1197 (
1198 "src/second.cs",
1199 Value::object(vec![("purpose", Value::string("alpha beta"))]),
1200 ),
1201 (
1202 "src/first.cs",
1203 Value::object(vec![("purpose", Value::string("beta alpha"))]),
1204 ),
1205 (
1206 "src/partial.cs",
1207 Value::object(vec![("purpose", Value::string("alpha only"))]),
1208 ),
1209 ]),
1210 )]);
1211 write_manifest(&root, &m).unwrap();
1212 assert_eq!(
1213 find_ranked(&root, "alpha beta", &[]),
1214 vec!["src/second.cs", "src/first.cs"]
1215 );
1216
1217 let m2 = Value::object(vec![(
1223 "entries",
1224 Value::object(vec![
1225 (
1226 "src/a.cs",
1227 Value::object(vec![("purpose", Value::string("alpha four"))]),
1228 ),
1229 (
1230 "src/b.cs",
1231 Value::object(vec![("purpose", Value::string("beta five"))]),
1232 ),
1233 (
1234 "src/c.cs",
1235 Value::object(vec![("purpose", Value::string("alpha beta six"))]),
1236 ),
1237 (
1238 "src/d.cs",
1239 Value::object(vec![("purpose", Value::string("seven"))]),
1240 ),
1241 ]),
1242 )]);
1243 write_manifest(&root, &m2).unwrap();
1244 assert_eq!(
1245 find_ranked(&root, "alpha beta gamma", &[]),
1246 vec!["src/c.cs", "src/a.cs", "src/b.cs"]
1247 );
1248 }
1249
1250 #[test]
1253 fn git_head_is_none_outside_a_repo() {
1254 let root = unique_temp_dir("nogit-head");
1255 assert_eq!(git_head(&root), None);
1256 }
1257
1258 #[test]
1261 fn scope_for_prefers_explicit_dirs_then_registry_then_dot() {
1262 let _guard = REGISTRY_ENV_LOCK.lock().unwrap();
1263 let scoped = unique_temp_dir("scope-scoped");
1264 let unscoped = unique_temp_dir("scope-unscoped");
1265 let reg_dir = unique_temp_dir("scope-reg");
1266 let reg_path = reg_dir.join("repos.json");
1267 fs::write(
1268 ®_path,
1269 format!(
1270 r#"{{"roots": [{{"root": "{}", "kind": "git", "scope": ["src", "app"]}}]}}"#,
1271 scoped.display()
1272 ),
1273 )
1274 .unwrap();
1275
1276 env::set_var("SCOUT_REGISTRY", ®_path);
1277 let result = (|| {
1278 assert_eq!(
1279 scope_for(&scoped, None).unwrap(),
1280 vec!["src".to_string(), "app".to_string()]
1281 );
1282 assert_eq!(scope_for(&unscoped, None).unwrap(), vec![".".to_string()]);
1283 let explicit = vec!["a".to_string(), "b".to_string()];
1284 assert_eq!(scope_for(&scoped, Some(&explicit)).unwrap(), explicit);
1285 })();
1286 env::remove_var("SCOUT_REGISTRY");
1287 result
1288 }
1289}