1use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use serde_json::{json, Value};
13
14pub const PROJECT_RULE_NAMES: &[&str] = &["AGENTS.md", "WARP.md"];
16pub const LINKED_RULE_NAMES: &[&str] = &[
18 "CLAUDE.md",
19 "AGENT.md",
20 "GEMINI.md",
21 ".cursorrules",
22 ".clinerules",
23 ".windsurfrules",
24];
25pub const LINKED_RULE_RELPATHS: &[&str] = &[".github/copilot-instructions.md"];
27
28pub 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
43pub const IGNORE_FILE_NAMES: &[&str] = &[
45 ".warpindexingignore",
46 ".cursorignore",
47 ".cursorindexingignore",
48 ".codeiumignore",
49 ".grokindexingignore",
50];
51
52pub const MAP_TOO_LARGE: usize = 5000;
54pub const MAP_LIST_CAP: usize = 500;
56pub 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#[must_use]
79pub fn git_root(cwd: &Path) -> Option<PathBuf> {
80 git_line(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
81}
82
83#[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#[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#[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#[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 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#[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#[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#[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 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 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#[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 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#[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#[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#[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 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 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 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; }
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 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}