Skip to main content

packset_daemon/
context.rs

1//! What the checkout says, as opposed to what the pack remembers.
2//!
3//! Rules, skills and the file outline are read from the working tree on every
4//! call and never written back. That separation is the point: the pack holds
5//! what somebody chose to remember, and this holds what is simply true of the
6//! directory right now, so neither can quietly become the other.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use serde_json::{json, Value};
13
14/// Rule files a project writes for itself.
15pub const PROJECT_RULE_NAMES: &[&str] = &["AGENTS.md", "WARP.md"];
16/// Rule files other tools read, which a project may also carry.
17pub const LINKED_RULE_NAMES: &[&str] = &[
18    "CLAUDE.md",
19    "AGENT.md",
20    "GEMINI.md",
21    ".cursorrules",
22    ".clinerules",
23    ".windsurfrules",
24];
25/// Linked rules that live at a fixed path under the root.
26pub const LINKED_RULE_RELPATHS: &[&str] = &[".github/copilot-instructions.md"];
27
28/// Directories a skill catalog may live in.
29pub const SKILL_DIR_NAMES: &[&str] = &[
30    ".agents/skills",
31    ".warp/skills",
32    ".claude/skills",
33    ".codex/skills",
34    ".cursor/skills",
35    ".gemini/skills",
36    ".copilot/skills",
37    ".factory/skills",
38    ".github/skills",
39    ".opencode/skills",
40    ".grok/skills",
41];
42
43/// Ignore files whose patterns narrow the outline.
44pub const IGNORE_FILE_NAMES: &[&str] = &[
45    ".warpindexingignore",
46    ".cursorignore",
47    ".cursorindexingignore",
48    ".codeiumignore",
49    ".grokindexingignore",
50];
51
52/// Past this many files the outline lists none of them.
53pub const MAP_TOO_LARGE: usize = 5000;
54/// The most paths one outline names.
55pub const MAP_LIST_CAP: usize = 500;
56/// The most characters one attached body carries.
57pub const ATTACH_CAP: usize = 32 * 1024;
58
59fn read_text(path: &Path) -> String {
60    std::fs::read_to_string(path).unwrap_or_default()
61}
62
63fn git_line(cwd: &Path, args: &[&str]) -> Option<String> {
64    let out = Command::new("git")
65        .arg("-C")
66        .arg(cwd)
67        .args(args)
68        .output()
69        .ok()?;
70    if !out.status.success() {
71        return None;
72    }
73    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
74    (!text.is_empty()).then_some(text)
75}
76
77/// The repository toplevel, or nothing when this is not a work tree.
78#[must_use]
79pub fn git_root(cwd: &Path) -> Option<PathBuf> {
80    git_line(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
81}
82
83/// The resolved git directory, which a worktree does not share.
84#[must_use]
85pub fn git_dir(cwd: &Path) -> Option<PathBuf> {
86    let text = git_line(cwd, &["rev-parse", "--git-dir"])?;
87    let path = PathBuf::from(text);
88    if path.is_absolute() {
89        Some(path)
90    } else {
91        std::fs::canonicalize(cwd.join(path)).ok()
92    }
93}
94
95fn resolve(path: &Path) -> PathBuf {
96    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
97}
98
99/// `cwd` first, then its parents, ending at `stop`.
100#[must_use]
101pub fn walk_to_root(cwd: &Path, stop: &Path) -> Vec<PathBuf> {
102    let mut current = resolve(cwd);
103    let stop = resolve(stop);
104    let mut out = Vec::new();
105    loop {
106        out.push(current.clone());
107        if current == stop || current.parent().is_none_or(|p| p == current) {
108            break;
109        }
110        match current.parent() {
111            Some(parent) => current = parent.to_path_buf(),
112            None => break,
113        }
114    }
115    out
116}
117
118/// A leading `--- key: value ---` block and the body after it.
119///
120/// Not YAML: one level of `key: value`, quotes stripped, and a file without the
121/// block is all body. A skill that meant to declare a name and did not is read
122/// as prose rather than half-parsed.
123#[must_use]
124pub fn parse_frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
125    let Some(rest) = text.strip_prefix("---") else {
126        return (BTreeMap::new(), text.to_string());
127    };
128    let rest = rest
129        .strip_prefix("\r\n")
130        .or_else(|| rest.strip_prefix('\n'))
131        .unwrap_or(rest);
132    let Some(end) = rest.find("\n---") else {
133        return (BTreeMap::new(), text.to_string());
134    };
135    let block = &rest[..end];
136    let body = &rest[end + 4..];
137    let body = body
138        .strip_prefix("\r\n")
139        .or_else(|| body.strip_prefix('\n'))
140        .unwrap_or(body);
141    let mut meta = BTreeMap::new();
142    for line in block.lines() {
143        let Some((key, value)) = line.split_once(':') else {
144            continue;
145        };
146        let name = key.trim().to_ascii_lowercase();
147        if name.is_empty() {
148            continue;
149        }
150        let value = value.trim().trim_matches('"').trim_matches('\'');
151        meta.insert(name, value.to_string());
152    }
153    (meta, body.to_string())
154}
155
156/// Rule files, most specific first.
157///
158/// Bodies stay on disk unless the caller asks: a listing is cheap and a client
159/// that only wants to know what exists should not pay for reading it.
160#[must_use]
161pub fn discover_rules(cwd: &Path, user_card: &Path) -> Vec<Value> {
162    let here = resolve(cwd);
163    let root = git_root(&here).unwrap_or_else(|| here.clone());
164    let mut found = Vec::new();
165    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
166
167    let mut add = |path: PathBuf, kind: &str, scope: &str, found: &mut Vec<Value>| {
168        if !path.is_file() {
169            return;
170        }
171        let resolved = resolve(&path);
172        if !seen.insert(resolved.clone()) {
173            return;
174        }
175        let name = path
176            .file_name()
177            .and_then(|n| n.to_str())
178            .unwrap_or_default()
179            .to_string();
180        let relpath = resolved
181            .strip_prefix(resolve(&root))
182            .map_or_else(|_| name.clone(), |p| p.display().to_string());
183        found.push(json!({
184            "path": resolved.display().to_string(),
185            "relpath": relpath,
186            "kind": kind,
187            "scope": scope,
188            "name": name,
189        }));
190    };
191
192    for directory in walk_to_root(&here, &root) {
193        let scope = if directory == here {
194            "cwd"
195        } else if directory == resolve(&root) {
196            "root"
197        } else {
198            "parent"
199        };
200        for name in PROJECT_RULE_NAMES {
201            add(directory.join(name), "project", scope, &mut found);
202        }
203        for name in LINKED_RULE_NAMES {
204            add(directory.join(name), "linked", scope, &mut found);
205        }
206        if directory == resolve(&root) {
207            for rel in LINKED_RULE_RELPATHS {
208                add(directory.join(rel), "linked", "root", &mut found);
209            }
210        }
211    }
212
213    if user_card.is_file() && !read_text(user_card).trim().is_empty() {
214        found.push(json!({
215            "path": resolve(user_card).display().to_string(),
216            "relpath": "USER.md",
217            "kind": "global",
218            "scope": "global",
219            "name": "USER.md",
220        }));
221    }
222    found
223}
224
225fn skill_dirs(cwd: &Path, root: &Path, home: &Path) -> Vec<(PathBuf, &'static str)> {
226    let mut pairs = Vec::new();
227    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
228    for directory in walk_to_root(cwd, root) {
229        for rel in SKILL_DIR_NAMES {
230            let candidate = directory.join(rel);
231            if !candidate.is_dir() {
232                continue;
233            }
234            if seen.insert(resolve(&candidate)) {
235                pairs.push((candidate, "project"));
236            }
237        }
238    }
239    for rel in SKILL_DIR_NAMES {
240        let candidate = home.join(rel);
241        if !candidate.is_dir() {
242            continue;
243        }
244        if seen.insert(resolve(&candidate)) {
245            pairs.push((candidate, "global"));
246        }
247    }
248    pairs
249}
250
251fn skill_from_dir(dir: &Path, scope: &str) -> Option<Value> {
252    let path = dir.join("SKILL.md");
253    if !path.is_file() {
254        return None;
255    }
256    let text = read_text(&path);
257    if text.trim().is_empty() {
258        return None;
259    }
260    let (meta, _body) = parse_frontmatter(&text);
261    let dir_name = dir
262        .file_name()
263        .and_then(|n| n.to_str())
264        .unwrap_or_default()
265        .to_string();
266    let name = meta
267        .get("name")
268        .map(|n| n.trim().to_string())
269        .filter(|n| !n.is_empty())
270        .unwrap_or_else(|| dir_name.clone());
271    // A skill with no declared description falls back to its first heading,
272    // which is what a reader would have skimmed anyway.
273    let description = meta
274        .get("description")
275        .map(|d| d.trim().to_string())
276        .filter(|d| !d.is_empty())
277        .or_else(|| {
278            text.lines()
279                .map(str::trim)
280                .find(|line| {
281                    line.starts_with('#') && !line.trim_start_matches('#').trim().is_empty()
282                })
283                .map(|line| line.trim_start_matches('#').trim().to_string())
284        })
285        .unwrap_or_default();
286    let mut supporting: Vec<String> = std::fs::read_dir(dir)
287        .into_iter()
288        .flatten()
289        .flatten()
290        .filter(|e| e.path().is_file())
291        .filter_map(|e| e.file_name().to_str().map(str::to_string))
292        .filter(|n| n != "SKILL.md")
293        .collect();
294    supporting.sort();
295    Some(json!({
296        "name": name,
297        "description": description,
298        "path": resolve(&path).display().to_string(),
299        "dir": resolve(dir).display().to_string(),
300        "scope": scope,
301        "supporting": supporting,
302    }))
303}
304
305/// The skill catalog: names, descriptions and paths, with no bodies.
306#[must_use]
307pub fn discover_skills(cwd: &Path, home: &Path) -> Vec<Value> {
308    let here = resolve(cwd);
309    let root = git_root(&here).unwrap_or_else(|| here.clone());
310    let mut skills = Vec::new();
311    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
312    for (directory, scope) in skill_dirs(&here, &root, home) {
313        let Ok(entries) = std::fs::read_dir(&directory) else {
314            continue;
315        };
316        let mut children: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
317        children.sort();
318        for child in children {
319            if !child.is_dir() {
320                continue;
321            }
322            let resolved = resolve(&child);
323            if seen.contains(&resolved) {
324                continue;
325            }
326            if let Some(entry) = skill_from_dir(&child, scope) {
327                seen.insert(resolved);
328                skills.push(entry);
329            }
330        }
331    }
332    skills
333}
334
335/// One skill in full: the first whose name or directory matches.
336#[must_use]
337pub fn read_skill(name: &str, cwd: &Path, home: &Path) -> Option<Value> {
338    let wanted = name.trim();
339    if wanted.is_empty() {
340        return None;
341    }
342    for entry in discover_skills(cwd, home) {
343        let matches_name = entry["name"].as_str() == Some(wanted);
344        let matches_dir = entry["dir"]
345            .as_str()
346            .map(Path::new)
347            .and_then(|d| d.file_name())
348            .and_then(|n| n.to_str())
349            == Some(wanted);
350        if !(matches_name || matches_dir) {
351            continue;
352        }
353        let path = PathBuf::from(entry["path"].as_str().unwrap_or_default());
354        let text = read_text(&path);
355        let (meta, body) = parse_frontmatter(&text);
356        let mut out = entry.as_object().cloned().unwrap_or_default();
357        out.insert("body".into(), Value::String(body));
358        out.insert(
359            "frontmatter".into(),
360            Value::Object(
361                meta.into_iter()
362                    .map(|(k, v)| (k, Value::String(v)))
363                    .collect(),
364            ),
365        );
366        out.insert("text".into(), Value::String(text));
367        return Some(Value::Object(out));
368    }
369    None
370}
371
372fn ignore_patterns(root: &Path) -> Vec<String> {
373    let mut patterns = Vec::new();
374    for name in IGNORE_FILE_NAMES {
375        let path = root.join(name);
376        if !path.is_file() {
377            continue;
378        }
379        for line in read_text(&path).lines() {
380            let trimmed = line.trim();
381            if trimmed.is_empty() || trimmed.starts_with('#') {
382                continue;
383            }
384            patterns.push(trimmed.to_string());
385        }
386    }
387    patterns
388}
389
390/// Whether a repository-relative path is ignored.
391#[must_use]
392pub fn is_ignored(rel: &str, patterns: &[String]) -> bool {
393    if patterns.is_empty() {
394        return false;
395    }
396    let name = Path::new(rel)
397        .file_name()
398        .and_then(|n| n.to_str())
399        .unwrap_or(rel);
400    let parts: Vec<&str> = rel.split('/').collect();
401    for pattern in patterns {
402        let bare = pattern.trim_end_matches('/');
403        if crate::glob::matches(rel, pattern) || crate::glob::matches(name, pattern) {
404            return true;
405        }
406        if crate::glob::matches(rel, bare) || crate::glob::matches(name, bare) {
407            return true;
408        }
409        // A trailing slash names a directory, so everything under it goes.
410        if pattern.ends_with('/') && (rel == bare || rel.starts_with(&format!("{bare}/"))) {
411            return true;
412        }
413        if parts.contains(&bare) {
414            return true;
415        }
416    }
417    false
418}
419
420fn git_ls_files(root: &Path) -> Result<Vec<String>, String> {
421    let out = Command::new("git")
422        .arg("-C")
423        .arg(root)
424        .args(["ls-files", "-z"])
425        .output()
426        .map_err(|e| e.to_string())?;
427    if !out.status.success() {
428        let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
429        return Err(if err.is_empty() {
430            "git ls-files failed".into()
431        } else {
432            err
433        });
434    }
435    Ok(out
436        .stdout
437        .split(|b| *b == 0)
438        .filter(|part| !part.is_empty())
439        .map(|part| String::from_utf8_lossy(part).into_owned())
440        .collect())
441}
442
443fn tree_outline(files: &[String]) -> Vec<Value> {
444    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
445    for rel in files {
446        let top = rel.split_once('/').map_or(".", |(head, _)| head);
447        *counts.entry(top.to_string()).or_insert(0) += 1;
448    }
449    // The root bucket sorts last, so a reader sees the directories first.
450    let mut keys: Vec<String> = counts.keys().cloned().collect();
451    keys.sort_by_key(|k| (k == ".", k.clone()));
452    keys.into_iter()
453        .map(|key| json!({"path": key, "files": counts[&key]}))
454        .collect()
455}
456
457/// The git-tracked outline of a checkout. Never a pack write.
458#[must_use]
459pub fn repo_map(cwd: &Path) -> Value {
460    let here = resolve(cwd);
461    let root = git_root(&here);
462    let gdir = git_dir(&here);
463    let git_dir_value = gdir
464        .as_ref()
465        .map_or(Value::Null, |p| Value::String(p.display().to_string()));
466
467    let Some(root) = root else {
468        return json!({
469            "status": "failed",
470            "reason": "not a git work tree",
471            "cwd": here.display().to_string(),
472            "root": Value::Null,
473            "git_dir": git_dir_value,
474            "count": 0,
475            "files": [],
476            "tree": [],
477        });
478    };
479
480    let listed = match git_ls_files(&root) {
481        Ok(files) => files,
482        Err(reason) => {
483            return json!({
484                "status": "failed",
485                "reason": reason,
486                "cwd": here.display().to_string(),
487                "root": root.display().to_string(),
488                "git_dir": git_dir_value,
489                "count": 0,
490                "files": [],
491                "tree": [],
492            })
493        }
494    };
495    let patterns = ignore_patterns(&root);
496    let files: Vec<String> = listed
497        .iter()
498        .filter(|rel| !is_ignored(rel, &patterns))
499        .cloned()
500        .collect();
501
502    let mut payload = json!({
503        "cwd": here.display().to_string(),
504        "root": root.display().to_string(),
505        "git_dir": git_dir_value,
506        "count": files.len(),
507        "ignored": listed.len() - files.len(),
508        "tree": tree_outline(&files),
509    });
510    let map = payload.as_object_mut().expect("object");
511    if files.len() > MAP_TOO_LARGE {
512        // A listing nobody can read is worse than the count alone.
513        map.insert("status".into(), json!("too-large"));
514        map.insert("files".into(), json!([]));
515        map.insert("listed".into(), json!(0));
516    } else {
517        map.insert("status".into(), json!("synced"));
518        let shown: Vec<&String> = files.iter().take(MAP_LIST_CAP).collect();
519        map.insert("listed".into(), json!(shown.len()));
520        map.insert("files".into(), json!(shown));
521    }
522    payload
523}
524
525/// A file's contents when `raw` names one, else `raw` itself, capped.
526#[must_use]
527pub fn read_attach_source(raw: &str, cap: usize) -> String {
528    let text = raw.trim();
529    if text.is_empty() {
530        return String::new();
531    }
532    let expanded = if let Some(rest) = text.strip_prefix("~/") {
533        std::env::var_os("HOME").map_or_else(
534            || PathBuf::from(text),
535            |home| PathBuf::from(home).join(rest),
536        )
537    } else {
538        PathBuf::from(text)
539    };
540    let body = if expanded.is_file() {
541        read_text(&expanded)
542    } else {
543        raw.to_string()
544    };
545    if body.chars().count() > cap {
546        body.chars().take(cap).collect()
547    } else {
548        body
549    }
550}
551
552/// The `/v1/rules` answer.
553#[must_use]
554pub fn rules_payload(cwd: &Path, user_card: &Path, with_body: bool) -> Value {
555    let mut rules = discover_rules(cwd, user_card);
556    if with_body {
557        for rule in &mut rules {
558            let path = PathBuf::from(rule["path"].as_str().unwrap_or_default());
559            if let Some(map) = rule.as_object_mut() {
560                map.insert("text".into(), Value::String(read_text(&path)));
561            }
562        }
563    }
564    json!({"rules": rules, "cwd": resolve(cwd).display().to_string()})
565}
566
567/// The `/v1/skills` answer, for the catalog or for one skill.
568#[must_use]
569pub fn skills_payload(cwd: &Path, home: &Path, name: Option<&str>) -> Value {
570    let here = resolve(cwd).display().to_string();
571    match name {
572        Some(wanted) => match read_skill(wanted, cwd, home) {
573            Some(skill) => json!({"skills": [skill], "cwd": here, "name": wanted}),
574            None => json!({"skills": [], "cwd": here, "name": wanted}),
575        },
576        None => json!({"skills": discover_skills(cwd, home), "cwd": here}),
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use std::fs;
584
585    #[test]
586    fn frontmatter_is_one_level_and_quotes_come_off() {
587        let (meta, body) = parse_frontmatter("---\nname: \"one\"\ndescription: 'two'\n---\nbody\n");
588        assert_eq!(meta.get("name").map(String::as_str), Some("one"));
589        assert_eq!(meta.get("description").map(String::as_str), Some("two"));
590        assert_eq!(body, "body\n");
591    }
592
593    #[test]
594    fn a_file_with_no_block_is_all_body() {
595        let (meta, body) = parse_frontmatter("# A heading\ntext\n");
596        assert!(meta.is_empty());
597        assert_eq!(body, "# A heading\ntext\n");
598    }
599
600    #[test]
601    fn an_unterminated_block_is_read_as_prose() {
602        // Half-parsing a file that meant to declare a name would put a stray
603        // key in the catalog, so the whole thing stays body.
604        let (meta, body) = parse_frontmatter("---\nname: one\nno closing marker\n");
605        assert!(meta.is_empty());
606        assert!(body.starts_with("---"));
607    }
608
609    #[test]
610    fn the_outline_counts_by_top_level_and_puts_the_root_last() {
611        let files: Vec<String> = ["src/a.rs", "src/b.rs", "README.md", "docs/x.md"]
612            .iter()
613            .map(|s| (*s).to_string())
614            .collect();
615        let tree = tree_outline(&files);
616        let names: Vec<&str> = tree.iter().map(|t| t["path"].as_str().unwrap()).collect();
617        assert_eq!(names, vec!["docs", "src", "."]);
618        assert_eq!(tree[1]["files"], json!(2));
619    }
620
621    #[test]
622    fn an_ignore_pattern_matches_a_name_a_path_and_a_directory() {
623        let patterns = vec![
624            "*.lock".to_string(),
625            "target/".to_string(),
626            "vendor".to_string(),
627        ];
628        assert!(is_ignored("Cargo.lock", &patterns));
629        assert!(is_ignored("a/b/Cargo.lock", &patterns));
630        assert!(is_ignored("target/debug/x", &patterns));
631        assert!(is_ignored("a/vendor/b", &patterns), "a path component");
632        assert!(!is_ignored("src/main.rs", &patterns));
633        assert!(!is_ignored("src/main.rs", &[]));
634    }
635
636    /// A repository, or None when git is not on this machine.
637    fn git_repo() -> Option<tempfile::TempDir> {
638        let dir = tempfile::tempdir().unwrap();
639        let ok = Command::new("git")
640            .arg("-C")
641            .arg(dir.path())
642            .arg("init")
643            .output()
644            .ok()
645            .is_some_and(|o| o.status.success());
646        ok.then_some(dir)
647    }
648
649    #[test]
650    fn rules_are_found_most_specific_first_up_to_the_repository_root() {
651        let Some(dir) = git_repo() else { return };
652        let nested = dir.path().join("a/b");
653        fs::create_dir_all(&nested).unwrap();
654        fs::write(dir.path().join("AGENTS.md"), "root rules").unwrap();
655        fs::write(nested.join("AGENTS.md"), "nested rules").unwrap();
656
657        let card = dir.path().join("nothing.md");
658        let found = discover_rules(&nested, &card);
659        let scopes: Vec<&str> = found.iter().map(|r| r["scope"].as_str().unwrap()).collect();
660        assert_eq!(found.len(), 2, "{found:?}");
661        assert_eq!(scopes, vec!["cwd", "root"], "nearest first: {found:?}");
662        assert_eq!(found[1]["relpath"], json!("AGENTS.md"));
663    }
664
665    #[test]
666    fn outside_a_work_tree_the_walk_is_just_the_directory() {
667        // The walk stops at the repository root, and with no repository the
668        // root is the directory itself. A rule in a parent is somebody else's.
669        let dir = tempfile::tempdir().unwrap();
670        let nested = dir.path().join("a/b");
671        fs::create_dir_all(&nested).unwrap();
672        fs::write(dir.path().join("AGENTS.md"), "parent rules").unwrap();
673        fs::write(nested.join("AGENTS.md"), "nested rules").unwrap();
674        if git_root(&nested).is_some() {
675            return; // the temp dir landed inside a checkout
676        }
677        let card = dir.path().join("nothing.md");
678        let found = discover_rules(&nested, &card);
679        assert_eq!(found.len(), 1, "{found:?}");
680        assert_eq!(found[0]["scope"], json!("cwd"));
681    }
682
683    #[test]
684    fn the_seat_card_joins_the_rules_only_when_it_says_something() {
685        let dir = tempfile::tempdir().unwrap();
686        let card = dir.path().join("USER.md");
687        assert!(discover_rules(dir.path(), &card).is_empty());
688        fs::write(&card, "   \n").unwrap();
689        assert!(
690            discover_rules(dir.path(), &card).is_empty(),
691            "blank is nothing"
692        );
693        fs::write(&card, "A standing preference.\n").unwrap();
694        let found = discover_rules(dir.path(), &card);
695        assert_eq!(found.len(), 1);
696        assert_eq!(found[0]["kind"], json!("global"));
697    }
698
699    #[test]
700    fn a_skill_declares_its_name_or_takes_its_directory() {
701        let dir = tempfile::tempdir().unwrap();
702        let skills = dir.path().join(".agents/skills");
703        fs::create_dir_all(skills.join("declared")).unwrap();
704        fs::create_dir_all(skills.join("undeclared")).unwrap();
705        fs::write(
706            skills.join("declared/SKILL.md"),
707            "---\nname: a-better-name\ndescription: does a thing\n---\nbody\n",
708        )
709        .unwrap();
710        fs::write(
711            skills.join("undeclared/SKILL.md"),
712            "# Fallback heading\nbody\n",
713        )
714        .unwrap();
715        fs::write(skills.join("declared/helper.py"), "x").unwrap();
716
717        let home = dir.path().join("nohome");
718        let found = discover_skills(dir.path(), &home);
719        let by_name: BTreeMap<&str, &Value> = found
720            .iter()
721            .map(|s| (s["name"].as_str().unwrap(), s))
722            .collect();
723        assert_eq!(
724            by_name["a-better-name"]["description"],
725            json!("does a thing")
726        );
727        assert_eq!(
728            by_name["a-better-name"]["supporting"],
729            json!(["helper.py"]),
730            "SKILL.md itself is not supporting"
731        );
732        assert_eq!(
733            by_name["undeclared"]["description"],
734            json!("Fallback heading"),
735            "the first heading stands in"
736        );
737    }
738
739    #[test]
740    fn a_directory_without_a_skill_file_is_not_a_skill() {
741        let dir = tempfile::tempdir().unwrap();
742        let skills = dir.path().join(".agents/skills");
743        fs::create_dir_all(skills.join("empty")).unwrap();
744        fs::create_dir_all(skills.join("blank")).unwrap();
745        fs::write(skills.join("blank/SKILL.md"), "   \n").unwrap();
746        let home = dir.path().join("nohome");
747        assert!(discover_skills(dir.path(), &home).is_empty());
748    }
749
750    #[test]
751    fn reading_one_skill_adds_the_body_and_the_frontmatter() {
752        let dir = tempfile::tempdir().unwrap();
753        let skills = dir.path().join(".claude/skills/deploy");
754        fs::create_dir_all(&skills).unwrap();
755        fs::write(
756            skills.join("SKILL.md"),
757            "---\nname: deploy\ndescription: ship it\n---\nthe steps\n",
758        )
759        .unwrap();
760        let home = dir.path().join("nohome");
761        let skill = read_skill("deploy", dir.path(), &home).unwrap();
762        assert_eq!(skill["body"], json!("the steps\n"));
763        assert_eq!(skill["frontmatter"]["description"], json!("ship it"));
764        assert!(skill["text"].as_str().unwrap().starts_with("---"));
765        assert!(read_skill("nope", dir.path(), &home).is_none());
766        assert!(read_skill("", dir.path(), &home).is_none());
767    }
768
769    #[test]
770    fn an_attach_source_is_a_path_or_the_text_itself() {
771        let dir = tempfile::tempdir().unwrap();
772        let path = dir.path().join("body.txt");
773        fs::write(&path, "from the file").unwrap();
774        assert_eq!(
775            read_attach_source(path.to_str().unwrap(), ATTACH_CAP),
776            "from the file"
777        );
778        assert_eq!(read_attach_source("not a path", ATTACH_CAP), "not a path");
779        assert_eq!(read_attach_source("   ", ATTACH_CAP), "");
780        assert_eq!(read_attach_source("abcdef", 3), "abc");
781    }
782
783    #[test]
784    fn a_map_outside_a_work_tree_says_so_rather_than_guessing() {
785        let dir = tempfile::tempdir().unwrap();
786        let map = repo_map(dir.path());
787        // A temp dir may sit inside somebody's checkout, so either answer is
788        // legitimate; what matters is that a failure names its reason.
789        if map["status"] == json!("failed") {
790            assert_eq!(map["reason"], json!("not a git work tree"));
791            assert_eq!(map["count"], json!(0));
792        } else {
793            assert_eq!(map["status"], json!("synced"));
794        }
795    }
796}