1use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11use regex_lite::Regex;
12use serde_json::Value;
13pub use yapi_types::rpc::SourceInfo;
14pub use yapi_types::system_prompt::{ContextFile, Skill};
15
16use crate::config::PROJECT_DIR;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum Diagnostic {
22 Warning {
24 message: String,
26 path: PathBuf,
28 },
29 Error {
31 message: String,
33 path: PathBuf,
35 },
36 Collision {
38 name: String,
40 winner: SourceInfo,
42 loser: PathBuf,
44 },
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct PromptTemplate {
50 pub name: String,
52 pub description: String,
54 pub argument_hint: Option<String>,
56 pub content: String,
58 pub file_path: PathBuf,
60 pub source: SourceInfo,
62}
63
64pub type Frontmatter = serde_json::Map<String, Value>;
67
68pub fn split_frontmatter(content: &str) -> (Option<String>, String) {
71 let normalized = content
72 .strip_prefix('\u{feff}')
73 .unwrap_or(content)
74 .replace("\r\n", "\n")
75 .replace('\r', "\n");
76 match normalized
77 .starts_with("---")
78 .then(|| normalized[3..].find("\n---"))
79 .flatten()
80 {
81 Some(end) => (
82 Some(normalized.get(4..end + 3).unwrap_or_default().to_owned()),
83 normalized[end + 7..].trim().to_owned(),
84 ),
85 None => (None, normalized),
86 }
87}
88
89pub fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), String> {
91 let (yaml, body) = split_frontmatter(content);
92 let Some(yaml) = yaml.filter(|yaml| !yaml.is_empty()) else {
93 return Ok((Frontmatter::new(), body));
94 };
95 let documents = load_yaml(yaml).map_err(|error| error.to_string())?;
96 let frontmatter = match documents.into_iter().next().map(yaml_to_json) {
97 Some(Value::Object(map)) => map,
98 _ => Frontmatter::new(),
99 };
100 Ok((frontmatter, body))
101}
102
103fn load_yaml(mut yaml: String) -> Result<Vec<yaml_rust2::Yaml>, yaml_rust2::ScanError> {
106 loop {
107 match yaml_rust2::YamlLoader::load_from_str(&yaml) {
108 Err(error) if error.info() == "':' must be followed by a valid YAML whitespace" => {
109 let at = yaml
111 .char_indices()
112 .nth(error.marker().index())
113 .map_or(yaml.len(), |(at, _)| at);
114 let start = yaml[..at].trim_end_matches('\t').len();
115 if start == at {
116 return Err(error);
117 }
118 yaml.replace_range(start..=start, " ");
119 }
120 result => return result,
121 }
122 }
123}
124
125fn yaml_to_json(yaml: yaml_rust2::Yaml) -> Value {
127 use yaml_rust2::Yaml;
128 match yaml {
129 Yaml::Real(_) => yaml
130 .as_f64()
131 .and_then(serde_json::Number::from_f64)
132 .map_or(Value::Null, Value::Number),
133 Yaml::Integer(number) => Value::from(number),
134 Yaml::String(text) => Value::String(text),
135 Yaml::Boolean(flag) => Value::Bool(flag),
136 Yaml::Array(items) => Value::Array(items.into_iter().map(yaml_to_json).collect()),
137 Yaml::Hash(entries) => Value::Object(
138 entries
139 .into_iter()
140 .map(|(key, value)| {
141 let key = match yaml_to_json(key) {
142 Value::String(text) => text,
143 Value::Null => String::new(),
144 other => other.to_string(),
145 };
146 (key, yaml_to_json(value))
147 })
148 .collect(),
149 ),
150 Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => Value::Null,
151 }
152}
153
154fn read_text(path: &Path) -> Option<String> {
155 std::fs::read_to_string(path).ok().map(|text| {
156 text.strip_prefix('\u{feff}')
157 .map(str::to_owned)
158 .unwrap_or(text)
159 })
160}
161
162fn context_file_in(dir: &Path) -> Option<ContextFile> {
163 for name in [
164 "AGENTS.override.md",
165 "AGENTS.md",
166 "AGENTS.MD",
167 "CLAUDE.md",
168 "CLAUDE.MD",
169 ] {
170 let path = dir.join(name);
171 if path.is_file()
172 && let Some(content) = read_text(&path)
173 {
174 return Some(ContextFile { path, content });
175 }
176 }
177 None
178}
179
180fn git_paths(cwd: &Path) -> Option<(PathBuf, PathBuf)> {
183 for dir in cwd.ancestors() {
184 let git = dir.join(".git");
185 if git.is_dir() {
186 return git.join("HEAD").exists().then(|| (dir.to_path_buf(), git));
187 }
188 if !git.is_file() {
189 continue;
190 }
191 let text = std::fs::read_to_string(&git).ok()?;
192 let Some(target) = text.trim().strip_prefix("gitdir: ") else {
193 continue;
194 };
195 let git_dir = crate::tools::path::resolve_lexically(dir, Path::new(target.trim()));
196 if !git_dir.join("HEAD").exists() {
197 return None;
198 }
199 let common = git_dir.join("commondir");
200 let common = if common.exists() {
201 let text = std::fs::read_to_string(common).ok()?;
202 crate::tools::path::resolve_lexically(&git_dir, Path::new(text.trim()))
203 } else {
204 git_dir
205 };
206 return Some((dir.to_path_buf(), common));
207 }
208 None
209}
210
211fn shadowed_context_file(cwd: &Path) -> Option<PathBuf> {
215 let canonical =
216 |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
217 let (repo_dir, common_git_dir) = git_paths(cwd)?;
218 let common_git_dir = canonical(&common_git_dir);
219 let worktree = canonical(&repo_dir);
220 let main = common_git_dir.parent()?;
221 if worktree == main
224 || !worktree.starts_with(main)
225 || canonical(&main.join(".git")) != common_git_dir
226 {
227 return None;
228 }
229 Some(main.join(context_file_in(&worktree)?.path.file_name()?))
230}
231
232pub fn context_files(project_dir: &Path, agent_dir: &Path) -> Vec<ContextFile> {
237 let mut files = Vec::new();
238 if let Some(global) = context_file_in(agent_dir) {
239 files.push(global);
240 }
241 let shadowed = shadowed_context_file(project_dir);
242 let mut ancestors = Vec::new();
243 for dir in project_dir.ancestors() {
244 if let Some(file) = context_file_in(dir)
245 && shadowed.as_ref().is_none_or(|shadowed| {
246 std::fs::canonicalize(&file.path).ok().as_ref() != Some(shadowed)
247 })
248 && !files
249 .iter()
250 .any(|seen: &ContextFile| seen.path == file.path)
251 && !ancestors
252 .iter()
253 .any(|seen: &ContextFile| seen.path == file.path)
254 {
255 ancestors.insert(0, file);
256 }
257 }
258 files.extend(ancestors);
259 files
260}
261
262fn found_at(origin: &SourceInfo, path: &Path) -> SourceInfo {
265 SourceInfo {
266 path: path.display().to_string(),
267 ..origin.clone()
268 }
269}
270
271fn first_by_name<T>(
276 found: Vec<T>,
277 meta: impl Fn(&T) -> (&str, &SourceInfo, &Path),
278 real_path: impl Fn(&T) -> Option<PathBuf>,
279) -> (Vec<T>, Vec<Diagnostic>) {
280 let mut kept: Vec<T> = Vec::new();
281 let mut files = HashSet::new();
282 let mut collisions = Vec::new();
283 for item in found {
284 let real = real_path(&item);
285 if real.as_ref().is_some_and(|real| files.contains(real)) {
286 continue;
287 }
288 let (name, _, file) = meta(&item);
289 match kept.iter().find(|existing| meta(existing).0 == name) {
290 Some(existing) => collisions.push(Diagnostic::Collision {
291 name: name.to_owned(),
292 winner: meta(existing).1.clone(),
293 loser: file.to_path_buf(),
294 }),
295 None => {
296 files.extend(real);
297 kept.push(item);
298 }
299 }
300 }
301 (kept, collisions)
302}
303
304pub fn cli_source(path: &Path) -> SourceInfo {
307 SourceInfo {
308 path: path.display().to_string(),
309 source: "cli".into(),
310 scope: "temporary".into(),
311 origin: "top-level".into(),
312 base_dir: None,
313 }
314}
315
316const MAX_NAME_LENGTH: usize = 64;
319const MAX_DESCRIPTION_LENGTH: usize = 1024;
320
321fn name_problems(name: &str) -> Vec<String> {
323 let mut problems = Vec::new();
324 let length = yapi_types::js::len(name);
325 if length > MAX_NAME_LENGTH {
326 problems.push(format!(
327 "name exceeds {MAX_NAME_LENGTH} characters ({length})"
328 ));
329 }
330 if name.is_empty()
331 || !name
332 .chars()
333 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
334 {
335 problems.push(
336 "name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
337 .to_owned(),
338 );
339 }
340 if name.starts_with('-') || name.ends_with('-') {
341 problems.push("name must not start or end with a hyphen".to_owned());
342 }
343 if name.contains("--") {
344 problems.push("name must not contain consecutive hyphens".to_owned());
345 }
346 problems
347}
348
349fn load_skill(
353 path: &Path,
354 origin: &SourceInfo,
355 diagnostics: &mut Vec<Diagnostic>,
356) -> Option<Skill> {
357 let warn = |message: String| Diagnostic::Warning {
358 message,
359 path: path.to_path_buf(),
360 };
361 let declared = path.file_name().is_some_and(|name| name == "SKILL.md");
362 let text = match std::fs::read_to_string(path) {
364 Ok(text) => text,
365 Err(error) => {
366 diagnostics.push(warn(crate::tools::node_error(&error, "open", path)));
367 return None;
368 }
369 };
370 let frontmatter = match parse_frontmatter(&text) {
371 Ok((frontmatter, _)) => frontmatter,
372 Err(error) => {
373 if declared {
374 diagnostics.push(warn(error));
375 }
376 return None;
377 }
378 };
379 let description = frontmatter
380 .get("description")
381 .and_then(Value::as_str)
382 .filter(|text| !text.trim().is_empty());
383 if !declared && description.is_none() {
384 return None;
385 }
386 match description {
387 None => diagnostics.push(warn("description is required".to_owned())),
388 Some(text) => {
389 let length = yapi_types::js::len(text);
390 if length > MAX_DESCRIPTION_LENGTH {
391 diagnostics.push(warn(format!(
392 "description exceeds {MAX_DESCRIPTION_LENGTH} characters ({length})"
393 )));
394 }
395 }
396 }
397 let base_dir = path.parent()?.to_path_buf();
398 let name = frontmatter
399 .get("name")
400 .and_then(Value::as_str)
401 .filter(|name| !name.is_empty())
402 .map(str::to_owned)
403 .or_else(|| {
404 base_dir
405 .file_name()
406 .map(|name| name.to_string_lossy().into_owned())
407 })?;
408 diagnostics.extend(name_problems(&name).into_iter().map(warn));
409 Some(Skill {
410 name,
411 description: description?.to_owned(),
412 file_path: path.to_path_buf(),
413 base_dir,
414 disable_model_invocation: frontmatter.get("disable-model-invocation")
415 == Some(&Value::Bool(true)),
416 source: found_at(origin, path),
417 })
418}
419
420fn skills_in(
424 dir: &Path,
425 top: bool,
426 origin: &SourceInfo,
427 skills: &mut Vec<Skill>,
428 diagnostics: &mut Vec<Diagnostic>,
429) {
430 let skill_file = dir.join("SKILL.md");
431 if skill_file.is_file() {
432 skills.extend(load_skill(&skill_file, origin, diagnostics));
433 return;
434 }
435 let Ok(read) = std::fs::read_dir(dir) else {
436 return;
437 };
438 let mut entries: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
439 entries.sort();
440 for path in entries {
441 let name = path
442 .file_name()
443 .map(|name| name.to_string_lossy().into_owned())
444 .unwrap_or_default();
445 if name.starts_with('.') || name == "node_modules" {
446 continue;
447 }
448 if path.is_dir() {
449 skills_in(&path, false, origin, skills, diagnostics);
450 } else if top && path.is_file() && name.ends_with(".md") {
451 skills.extend(load_skill(&path, origin, diagnostics));
452 }
453 }
454}
455
456pub fn skills_from(sources: &[SourceInfo]) -> (Vec<Skill>, Vec<Diagnostic>) {
460 let mut found = Vec::new();
461 let mut diagnostics = Vec::new();
462 for source in sources {
463 let path = Path::new(&source.path);
464 let warn = |message: &str| Diagnostic::Warning {
465 message: message.to_owned(),
466 path: path.to_path_buf(),
467 };
468 if !path.exists() {
469 diagnostics.push(warn("skill path does not exist"));
470 } else if path.is_dir() {
471 skills_in(path, true, source, &mut found, &mut diagnostics);
472 } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
473 found.extend(load_skill(path, source, &mut diagnostics));
474 } else {
475 diagnostics.push(warn("skill path is not a markdown file"));
476 }
477 }
478 let (skills, collisions) = first_by_name(
480 found,
481 |skill| (&skill.name, &skill.source, &skill.file_path),
482 |skill| Some(std::fs::canonicalize(&skill.file_path).unwrap_or(skill.file_path.clone())),
483 );
484 diagnostics.extend(collisions);
485 (skills, diagnostics)
486}
487
488pub fn merge_sources(sources: impl IntoIterator<Item = SourceInfo>) -> Vec<SourceInfo> {
491 let mut seen = HashSet::new();
492 sources
493 .into_iter()
494 .filter(|info| {
495 seen.insert(
496 std::fs::canonicalize(&info.path).unwrap_or_else(|_| PathBuf::from(&info.path)),
497 )
498 })
499 .collect()
500}
501
502fn escape_xml(text: &str) -> String {
503 text.replace('&', "&")
504 .replace('<', "<")
505 .replace('>', ">")
506 .replace('"', """)
507 .replace('\'', "'")
508}
509
510pub fn format_skills(skills: &[Skill], read_tool: &str) -> String {
512 let visible: Vec<&Skill> = skills
513 .iter()
514 .filter(|skill| !skill.disable_model_invocation)
515 .collect();
516 if visible.is_empty() {
517 return String::new();
518 }
519 let mut lines = vec![
520 "\n\nThe following skills provide specialized instructions for specific tasks.".to_owned(),
521 if read_tool == "read" {
522 "Use the read tool to load a skill's file when the task matches its description."
523 } else {
524 "Use bash to load a skill's file when the task matches its description."
525 }
526 .to_owned(),
527 "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.".to_owned(),
528 String::new(),
529 "<available_skills>".to_owned(),
530 ];
531 for skill in visible {
532 lines.push(" <skill>".into());
533 lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
534 lines.push(format!(
535 " <description>{}</description>",
536 escape_xml(&skill.description)
537 ));
538 lines.push(format!(
539 " <location>{}</location>",
540 escape_xml(&skill.file_path.to_string_lossy())
541 ));
542 lines.push(" </skill>".into());
543 }
544 lines.push("</available_skills>".into());
545 lines.join("\n")
546}
547
548fn templates_in(
549 dir: &Path,
550 origin: &SourceInfo,
551 templates: &mut Vec<PromptTemplate>,
552 diagnostics: &mut Vec<Diagnostic>,
553) {
554 let Ok(read) = std::fs::read_dir(dir) else {
555 return;
556 };
557 let mut paths: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
558 paths.sort();
559 for path in paths {
560 if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
561 templates.extend(template_at(path, origin, diagnostics));
562 }
563 }
564}
565
566fn template_at(
569 path: PathBuf,
570 origin: &SourceInfo,
571 diagnostics: &mut Vec<Diagnostic>,
572) -> Option<PromptTemplate> {
573 let parsed = std::fs::read_to_string(&path)
574 .map_err(|error| crate::tools::node_error(&error, "open", &path))
575 .and_then(|text| parse_frontmatter(&text));
576 let (frontmatter, body) = match parsed {
577 Ok(parsed) => parsed,
578 Err(message) => {
579 diagnostics.push(Diagnostic::Warning { message, path });
580 return None;
581 }
582 };
583 let name = path
584 .file_stem()
585 .map(|stem| stem.to_string_lossy().into_owned())
586 .unwrap_or_default();
587 let mut description = frontmatter
588 .get("description")
589 .and_then(Value::as_str)
590 .unwrap_or_default()
591 .to_owned();
592 if description.is_empty()
593 && let Some(first) = body.lines().find(|line| !line.trim().is_empty())
594 {
595 description = yapi_types::js::slice(first, 0, 60);
596 if yapi_types::js::len(first) > 60 {
597 description += "...";
598 }
599 }
600 Some(PromptTemplate {
601 name,
602 description,
603 argument_hint: frontmatter
604 .get("argument-hint")
605 .and_then(Value::as_str)
606 .map(str::to_owned),
607 content: body,
608 source: found_at(origin, &path),
609 file_path: path,
610 })
611}
612
613pub fn templates_from(sources: &[SourceInfo]) -> (Vec<PromptTemplate>, Vec<Diagnostic>) {
618 let mut templates: Vec<PromptTemplate> = Vec::new();
619 let mut diagnostics = Vec::new();
620 for source in sources {
621 let path = PathBuf::from(&source.path);
622 if path.is_dir() {
623 templates_in(&path, source, &mut templates, &mut diagnostics);
624 } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
625 templates.extend(template_at(path, source, &mut diagnostics));
626 }
627 }
628 let (templates, collisions) = first_by_name(
629 templates,
630 |template| (&template.name, &template.source, &template.file_path),
631 |_| None,
632 );
633 diagnostics.extend(collisions);
634 (templates, diagnostics)
635}
636
637pub fn parse_command_args(text: &str) -> Vec<String> {
639 let mut args = Vec::new();
640 let mut current = String::new();
641 let mut quote: Option<char> = None;
642 for c in text.chars() {
643 match quote {
644 Some(q) if c == q => quote = None,
645 Some(_) => current.push(c),
646 None if c == '"' || c == '\'' => quote = Some(c),
647 None if c.is_whitespace() => {
648 if !current.is_empty() {
649 args.push(std::mem::take(&mut current));
650 }
651 }
652 None => current.push(c),
653 }
654 }
655 if !current.is_empty() {
656 args.push(current);
657 }
658 args
659}
660
661pub fn substitute_args(content: &str, args: &[String]) -> String {
664 static PLACEHOLDER: LazyLock<Option<Regex>> = LazyLock::new(|| {
665 Regex::new(r"\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)")
666 .ok()
667 });
668 let Some(placeholder) = PLACEHOLDER.as_ref() else {
669 return content.to_owned();
670 };
671 let all = args.join(" ");
672 let number = |digits: &str| digits.parse::<usize>().unwrap_or(usize::MAX);
674 let arg = |digits: &str| {
675 number(digits)
676 .checked_sub(1)
677 .and_then(|index| args.get(index))
678 .map_or("", String::as_str)
679 };
680 let all_or_arg = |target: &str| match target {
681 "@" | "ARGUMENTS" => all.as_str(),
682 digits => arg(digits),
683 };
684 placeholder
685 .replace_all(content, |caps: ®ex_lite::Captures<'_>| {
686 if let Some(target) = caps.get(1) {
687 let value = all_or_arg(target.as_str());
688 let default = caps.get(2).map_or("", |default| default.as_str());
689 return if value.is_empty() { default } else { value }.to_owned();
690 }
691 if let Some(start) = caps.get(3) {
692 let start = number(start.as_str()).saturating_sub(1).min(args.len());
693 let end = caps.get(4).map_or(args.len(), |length| {
694 start
695 .saturating_add(number(length.as_str()))
696 .min(args.len())
697 });
698 return args[start..end].join(" ");
699 }
700 caps.get(5)
701 .map_or("", |simple| all_or_arg(simple.as_str()))
702 .to_owned()
703 })
704 .into_owned()
705}
706
707pub fn expand_prompt_template(text: &str, templates: &[PromptTemplate]) -> String {
709 let Some(rest) = text.strip_prefix('/') else {
710 return text.to_owned();
711 };
712 let (name, args) = match rest.find(char::is_whitespace) {
713 Some(index) => (&rest[..index], rest[index..].trim_start()),
714 None => (rest, ""),
715 };
716 if name.is_empty() {
717 return text.to_owned();
718 }
719 match templates.iter().find(|template| template.name == name) {
720 Some(template) => substitute_args(&template.content, &parse_command_args(args)),
721 None => text.to_owned(),
722 }
723}
724
725pub fn system_prompt_file(
728 project_dir: &Path,
729 agent_dir: &Path,
730 project_trusted: bool,
731 name: &str,
732) -> Option<String> {
733 let project = project_dir.join(PROJECT_DIR).join(name);
734 if project_trusted && project.exists() {
735 return read_text(&project);
736 }
737 read_text(&agent_dir.join(name))
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn parses_frontmatter() {
746 let (meta, body) = parse_frontmatter(
747 "---\nname: demo\ndescription: \"Does: things\"\ndisable-model-invocation: true\nnotes: |\n a\n b\nwhen: >-\n folded\n text\ntags: [a, b]\nlimit: 3\ntabbed:\tTabbed value\noffset:\t\t-1\n---\n\nBody\n",
748 )
749 .unwrap();
750 assert_eq!(
751 serde_json::Value::Object(meta),
752 serde_json::json!({
753 "name": "demo",
754 "description": "Does: things",
755 "disable-model-invocation": true,
756 "notes": "a\n b\n",
757 "when": "folded text",
758 "tags": ["a", "b"],
759 "limit": 3,
760 "tabbed": "Tabbed value",
761 "offset": -1,
762 })
763 );
764 assert_eq!(body, "Body");
765 assert!(parse_frontmatter("---\nname: a: b\n---\n").is_err());
767 assert!(parse_frontmatter("---\na: x\n\tb: y\n---\n").is_err());
768 assert!(parse_frontmatter("no frontmatter").unwrap().0.is_empty());
769 }
770
771 #[test]
772 fn reports_malformed_frontmatter() {
773 let dir = std::env::temp_dir().join(format!("yapi-frontmatter-{}", std::process::id()));
774 let _ = std::fs::remove_dir_all(&dir);
775 let bad = "---\ndescription: a: b\n---\nBody\n";
776 std::fs::create_dir_all(dir.join("prompts")).unwrap();
777 std::fs::create_dir_all(dir.join("skills/broken")).unwrap();
778 std::fs::write(dir.join("prompts/bad.md"), bad).unwrap();
779 std::fs::write(dir.join("prompts/good.md"), "Hello\n").unwrap();
780 std::fs::write(dir.join("skills/broken/SKILL.md"), bad).unwrap();
781 std::fs::write(dir.join("skills/loose.md"), bad).unwrap();
782
783 let (templates, diagnostics) = templates_from(&[cli_source(&dir.join("prompts"))]);
785 assert_eq!(templates.len(), 1);
786 assert!(matches!(
787 diagnostics.as_slice(),
788 [Diagnostic::Warning { path, .. }] if path.ends_with("prompts/bad.md")
789 ));
790
791 let (skills, diagnostics) = skills_from(&[cli_source(&dir.join("skills"))]);
793 assert!(skills.is_empty());
794 assert!(matches!(
795 diagnostics.as_slice(),
796 [Diagnostic::Warning { path, .. }] if path.ends_with("broken/SKILL.md")
797 ));
798 std::fs::remove_dir_all(&dir).unwrap();
799 }
800
801 #[test]
802 fn worktree_shadows_main() {
803 let dir = std::fs::canonicalize(std::env::temp_dir())
804 .unwrap()
805 .join(format!("yapi-worktree-{}", std::process::id()));
806 let _ = std::fs::remove_dir_all(&dir);
807 let main = dir.join("main");
808 let git_dir = main.join(".git/worktrees/feat");
809 let feat = main.join(".worktrees/feat");
810 std::fs::create_dir_all(&git_dir).unwrap();
811 std::fs::create_dir_all(&feat).unwrap();
812 std::fs::write(main.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
813 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feat\n").unwrap();
814 std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
815 std::fs::write(
816 feat.join(".git"),
817 format!("gitdir: {}\n", git_dir.display()),
818 )
819 .unwrap();
820 std::fs::write(main.join("AGENTS.md"), "main").unwrap();
821 let paths = || -> Vec<PathBuf> {
822 context_files(&feat, &dir.join("agent"))
823 .into_iter()
824 .map(|file| file.path)
825 .collect()
826 };
827 assert_eq!(paths(), [main.join("AGENTS.md")]);
829 std::fs::write(feat.join("AGENTS.md"), "feat").unwrap();
830 assert_eq!(paths(), [feat.join("AGENTS.md")]);
831 std::fs::remove_dir_all(&dir).unwrap();
832 }
833
834 #[test]
835 fn substitutes_arguments_like_pi() {
836 let args: Vec<String> = parse_command_args("one \"two three\" four");
837 assert_eq!(args, ["one", "two three", "four"]);
838 assert_eq!(
839 substitute_args("$1|$2|$9|$@", &args),
840 "one|two three||one two three four"
841 );
842 assert_eq!(substitute_args("${4:-none} ${1:-x}", &args), "none one");
843 assert_eq!(
844 substitute_args("${@:2} ${@:1:1} $ARGUMENTS", &args),
845 "two three four one one two three four"
846 );
847 assert_eq!(substitute_args("cost $ and $x", &args), "cost $ and $x");
848 }
849
850 #[test]
852 fn substitution_edge_cases() {
853 let args = |list: &[&str]| -> Vec<String> { list.iter().map(|&arg| arg.into()).collect() };
854 let ten = args(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
855 assert_eq!(substitute_args("$10|$1|$0|$11", &ten), "j|a||");
856 assert_eq!(
857 substitute_args(
858 "${0:-zero} ${1:-} ${2:-two} ${@:-none} ${ARGUMENTS:-none}",
859 &args(&["x"])
860 ),
861 "zero x two x x"
862 );
863 assert_eq!(
864 substitute_args("${@:-none}|${ARGUMENTS:-none}", &[]),
865 "none|none"
866 );
867 assert_eq!(
868 substitute_args(
869 "${@:0}|${@:2:1}|${@:5}|${@:2:0}|${@:1:99}",
870 &args(&["a", "b", "c"])
871 ),
872 "a b c|b|||a b c"
873 );
874 assert_eq!(
875 substitute_args(
876 "$ARGUMENTSX $@@ $$1 ${x} ${@:2:} ${1:-a}b}",
877 &args(&["one", "two"])
878 ),
879 "one twoX one two@ $one ${x} ${@:2:} oneb}"
880 );
881 assert_eq!(
882 substitute_args(
883 "$99999999999999999999|${@:99999999999999999999}|${99999999999999999999:-d}",
884 &args(&["a"])
885 ),
886 "||d"
887 );
888 assert_eq!(
889 substitute_args("${1:-multi\nline}|${2:-{nested}", &[]),
890 "multi\nline|{nested"
891 );
892 assert_eq!(
893 substitute_args("cost $ and $x and ${", &args(&["a"])),
894 "cost $ and $x and ${"
895 );
896 }
897}