Skip to main content

lucy/
context.rs

1use std::collections::BTreeMap;
2use std::ffi::OsStr;
3#[cfg(unix)]
4use std::ffi::{CStr, CString, OsString};
5use std::fs;
6use std::io::{self, Read};
7#[cfg(unix)]
8use std::os::fd::{AsRawFd, FromRawFd, RawFd};
9#[cfg(unix)]
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::path::{Component, Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use serde::{Deserialize, Serialize};
15
16use crate::config::config_dir;
17
18#[derive(Debug)]
19pub struct ContextError(String);
20
21impl ContextError {
22    fn new(message: impl Into<String>) -> Self {
23        Self(message.into())
24    }
25}
26
27impl std::fmt::Display for ContextError {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        formatter.write_str(&self.0)
30    }
31}
32
33impl std::error::Error for ContextError {}
34
35impl From<io::Error> for ContextError {
36    fn from(_error: io::Error) -> Self {
37        Self::new("instruction context discovery error")
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InstructionSource {
43    pub path: PathBuf,
44    pub contents: String,
45}
46
47/// A discovered Agent Skill. `contents` is retained so explicit invocations
48/// use the exact, symlink-safe snapshot discovered when the session started.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct SkillEntry {
51    pub name: String,
52    pub description: String,
53    pub path: PathBuf,
54    #[serde(default)]
55    pub contents: String,
56    #[serde(default = "default_model_invocable")]
57    pub model_invocable: bool,
58}
59
60fn default_model_invocable() -> bool {
61    true
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct BootContext {
66    pub system_prompt: String,
67    pub cwd: PathBuf,
68    pub instruction_files: Vec<InstructionSource>,
69    pub skills: Vec<SkillEntry>,
70}
71
72#[cfg(test)]
73fn resolve_boot_context(
74    home: &Path,
75    cwd: &Path,
76    configured_prompt: &str,
77) -> Result<BootContext, ContextError> {
78    resolve_boot_context_with_api_key_env(home, cwd, configured_prompt, None)
79}
80
81pub(crate) fn resolve_boot_context_with_api_key_env(
82    home: &Path,
83    cwd: &Path,
84    configured_prompt: &str,
85    api_key_env: Option<&str>,
86) -> Result<BootContext, ContextError> {
87    let cwd = fs::canonicalize(cwd)
88        .map_err(|_error| ContextError::new("unable to resolve working directory"))?;
89    let root = git_root(&cwd, api_key_env);
90    let project_directories = ancestor_directories(&root, &cwd);
91
92    let mut instruction_files = Vec::new();
93    if let Some(instruction) = preferred_instruction(&config_dir(home))? {
94        instruction_files.push(instruction);
95    }
96    for directory in &project_directories {
97        if let Some(instruction) = preferred_instruction(directory)? {
98            instruction_files.push(instruction);
99        }
100    }
101
102    let mut readme_files = Vec::new();
103    for directory in &project_directories {
104        if let Some(readme) = readme_for_directory(directory)? {
105            readme_files.push(readme);
106        }
107    }
108
109    // More-specific project locations override an earlier skill with the
110    // same declared name.
111    let mut skills = BTreeMap::new();
112    discover_skills(&home.join(".agents").join("skills"), &mut skills)?;
113    for directory in &project_directories {
114        discover_skills(&directory.join(".agents").join("skills"), &mut skills)?;
115    }
116    let skills = skills.into_values().collect::<Vec<_>>();
117    let system_prompt = build_system_prompt(
118        configured_prompt,
119        &cwd,
120        &instruction_files,
121        &readme_files,
122        &skills,
123    );
124
125    Ok(BootContext {
126        system_prompt,
127        cwd,
128        instruction_files,
129        skills,
130    })
131}
132
133fn git_root(cwd: &Path, api_key_env: Option<&str>) -> PathBuf {
134    let mut command = Command::new("git");
135    command
136        .arg("-C")
137        .arg(cwd)
138        .args(["rev-parse", "--show-toplevel"]);
139    if let Some(api_key_env) = api_key_env
140        .map(str::trim)
141        .filter(|api_key_env| !api_key_env.is_empty())
142    {
143        command.env_remove(api_key_env);
144    }
145    let output = command
146        .stdout(Stdio::piped())
147        .stderr(Stdio::null())
148        .output();
149
150    match output {
151        Ok(output) if output.status.success() => {
152            let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
153            if !text.is_empty() {
154                if let Ok(path) = fs::canonicalize(text) {
155                    return path;
156                }
157            }
158            cwd.to_owned()
159        }
160        _ => cwd.to_owned(),
161    }
162}
163
164fn ancestor_directories(root: &Path, cwd: &Path) -> Vec<PathBuf> {
165    let mut directories = Vec::new();
166    let mut current = cwd;
167    loop {
168        directories.push(current.to_owned());
169        if current == root {
170            break;
171        }
172        let Some(parent) = current.parent() else {
173            break;
174        };
175        if !cwd.starts_with(parent) || !parent.starts_with(root) {
176            break;
177        }
178        current = parent;
179    }
180    directories.reverse();
181    directories
182}
183
184#[cfg(unix)]
185struct ContextDirectory {
186    file: fs::File,
187}
188
189#[cfg(not(unix))]
190struct ContextDirectory {
191    path: PathBuf,
192}
193
194#[cfg(unix)]
195fn path_component_unavailable(error: &io::Error) -> bool {
196    error.kind() == io::ErrorKind::NotFound
197        || error.raw_os_error() == Some(libc::ENOTDIR)
198        || error.raw_os_error() == Some(libc::ELOOP)
199}
200
201#[cfg(unix)]
202fn open_directory_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
203    let name = CString::new(name.as_bytes())
204        .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
205    let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
206    let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
207    if fd < 0 {
208        let error = io::Error::last_os_error();
209        if path_component_unavailable(&error) {
210            return Ok(None);
211        }
212        return Err(error);
213    }
214    Ok(Some(unsafe { fs::File::from_raw_fd(fd) }))
215}
216
217#[cfg(unix)]
218fn open_instruction_file_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
219    let name = CString::new(name.as_bytes())
220        .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
221    let flags = libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC;
222    let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
223    if fd < 0 {
224        let error = io::Error::last_os_error();
225        if path_component_unavailable(&error) {
226            return Ok(None);
227        }
228        return Err(error);
229    }
230    let file = unsafe { fs::File::from_raw_fd(fd) };
231    if !file.metadata()?.is_file() {
232        return Ok(None);
233    }
234    Ok(Some(file))
235}
236
237#[cfg(unix)]
238fn open_file_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
239    let name = CString::new(name.as_bytes())
240        .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
241    let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC;
242    let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
243    if fd < 0 {
244        let error = io::Error::last_os_error();
245        if path_component_unavailable(&error) {
246            return Ok(None);
247        }
248        return Err(error);
249    }
250    let file = unsafe { fs::File::from_raw_fd(fd) };
251    if !file.metadata()?.is_file() {
252        return Ok(None);
253    }
254    Ok(Some(file))
255}
256
257#[cfg(unix)]
258impl ContextDirectory {
259    fn open(path: &Path) -> io::Result<Option<Self>> {
260        let start = if path.is_absolute() {
261            OsStr::new("/")
262        } else {
263            OsStr::new(".")
264        };
265        let Some(file) = open_directory_at(libc::AT_FDCWD, start)? else {
266            return Ok(None);
267        };
268        let mut directory = Self { file };
269
270        for component in path.components() {
271            let name = match component {
272                Component::Prefix(_) => {
273                    return Err(io::Error::new(
274                        io::ErrorKind::InvalidInput,
275                        "path prefix is not supported on Unix",
276                    ));
277                }
278                Component::RootDir | Component::CurDir => continue,
279                Component::ParentDir => OsStr::new(".."),
280                Component::Normal(name) => name,
281            };
282            let Some(file) = open_directory_at(directory.file.as_raw_fd(), name)? else {
283                return Ok(None);
284            };
285            directory = Self { file };
286        }
287
288        Ok(Some(directory))
289    }
290
291    fn open_child_directory(&self, name: &OsStr) -> io::Result<Option<Self>> {
292        let Some(file) = open_directory_at(self.file.as_raw_fd(), name)? else {
293            return Ok(None);
294        };
295        Ok(Some(Self { file }))
296    }
297
298    fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
299        open_instruction_file_at(self.file.as_raw_fd(), name)
300    }
301
302    fn open_regular_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
303        open_file_at(self.file.as_raw_fd(), name)
304    }
305
306    fn entries(&self) -> io::Result<Vec<OsString>> {
307        read_directory_entries(&self.file)
308    }
309}
310
311#[cfg(not(unix))]
312impl ContextDirectory {
313    fn open(path: &Path) -> io::Result<Option<Self>> {
314        match fs::symlink_metadata(path) {
315            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Ok(None),
316            Ok(_) => Ok(Some(Self {
317                path: path.to_owned(),
318            })),
319            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
320            Err(error) => Err(error),
321        }
322    }
323
324    fn open_child_directory(&self, name: &OsStr) -> io::Result<Option<Self>> {
325        Self::open(&self.path.join(name))
326    }
327
328    fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
329        open_instruction_file(&self.path.join(name))
330    }
331
332    fn open_regular_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
333        open_regular_file(&self.path.join(name))
334    }
335
336    fn entries(&self) -> io::Result<Vec<std::ffi::OsString>> {
337        fs::read_dir(&self.path)?
338            .map(|entry| entry.map(|entry| entry.file_name()))
339            .collect()
340    }
341}
342
343#[cfg(unix)]
344struct DirectoryStream(*mut libc::DIR);
345
346#[cfg(unix)]
347impl Drop for DirectoryStream {
348    fn drop(&mut self) {
349        unsafe {
350            libc::closedir(self.0);
351        }
352    }
353}
354
355#[cfg(unix)]
356fn reset_directory_errno() {
357    #[cfg(any(target_os = "linux", target_os = "android"))]
358    unsafe {
359        *libc::__errno_location() = 0;
360    }
361    #[cfg(any(
362        target_os = "macos",
363        target_os = "ios",
364        target_os = "tvos",
365        target_os = "watchos",
366        target_os = "freebsd",
367        target_os = "dragonfly",
368        target_os = "openbsd",
369        target_os = "netbsd"
370    ))]
371    unsafe {
372        *libc::__error() = 0;
373    }
374}
375
376#[cfg(unix)]
377fn directory_errno() -> libc::c_int {
378    #[cfg(any(target_os = "linux", target_os = "android"))]
379    {
380        unsafe { *libc::__errno_location() }
381    }
382    #[cfg(any(
383        target_os = "macos",
384        target_os = "ios",
385        target_os = "tvos",
386        target_os = "watchos",
387        target_os = "freebsd",
388        target_os = "dragonfly",
389        target_os = "openbsd",
390        target_os = "netbsd"
391    ))]
392    {
393        unsafe { *libc::__error() }
394    }
395    #[cfg(not(any(
396        target_os = "linux",
397        target_os = "android",
398        target_os = "macos",
399        target_os = "ios",
400        target_os = "tvos",
401        target_os = "watchos",
402        target_os = "freebsd",
403        target_os = "dragonfly",
404        target_os = "openbsd",
405        target_os = "netbsd"
406    )))]
407    {
408        0
409    }
410}
411
412#[cfg(unix)]
413fn read_directory_entries(file: &fs::File) -> io::Result<Vec<OsString>> {
414    let duplicate = unsafe { libc::dup(file.as_raw_fd()) };
415    if duplicate < 0 {
416        return Err(io::Error::last_os_error());
417    }
418    let directory = unsafe { libc::fdopendir(duplicate) };
419    if directory.is_null() {
420        let error = io::Error::last_os_error();
421        unsafe {
422            libc::close(duplicate);
423        }
424        return Err(error);
425    }
426    let directory = DirectoryStream(directory);
427    let mut entries = Vec::new();
428    loop {
429        reset_directory_errno();
430        let entry = unsafe { libc::readdir(directory.0) };
431        if entry.is_null() {
432            let error_number = directory_errno();
433            if error_number != 0 {
434                return Err(io::Error::from_raw_os_error(error_number));
435            }
436            break;
437        }
438        let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
439        if name != b"." && name != b".." {
440            entries.push(OsString::from_vec(name.to_vec()));
441        }
442    }
443    Ok(entries)
444}
445
446#[cfg(not(unix))]
447fn open_instruction_file(path: &Path) -> io::Result<Option<fs::File>> {
448    let file = match fs::File::open(path) {
449        Ok(file) => file,
450        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
451        Err(error) => return Err(error),
452    };
453    if !file.metadata()?.is_file() {
454        return Ok(None);
455    }
456    Ok(Some(file))
457}
458
459#[cfg(not(unix))]
460fn open_regular_file(path: &Path) -> io::Result<Option<fs::File>> {
461    let mut options = fs::OpenOptions::new();
462    options.read(true);
463    match fs::symlink_metadata(path) {
464        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
465            return Ok(None);
466        }
467        Ok(_) => {}
468        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
469        Err(error) => return Err(error),
470    }
471
472    let file = match options.open(path) {
473        Ok(file) => file,
474        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
475        Err(error) => return Err(error),
476    };
477    if !file.metadata()?.is_file() {
478        return Ok(None);
479    }
480    Ok(Some(file))
481}
482
483fn read_open_file(mut file: fs::File) -> io::Result<String> {
484    let mut contents = String::new();
485    file.read_to_string(&mut contents)?;
486    Ok(contents)
487}
488
489fn preferred_instruction(directory: &Path) -> Result<Option<InstructionSource>, ContextError> {
490    let Some(directory_fd) = ContextDirectory::open(directory)
491        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
492    else {
493        return Ok(None);
494    };
495
496    for name in [OsStr::new("AGENTS.md"), OsStr::new("CLAUDE.md")] {
497        let Some(file) = directory_fd
498            .open_instruction_file(name)
499            .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
500        else {
501            continue;
502        };
503        let contents = read_open_file(file)
504            .map_err(|_error| ContextError::new("unable to read instruction context"))?;
505        return Ok(Some(InstructionSource {
506            path: directory.join(name),
507            contents,
508        }));
509    }
510    Ok(None)
511}
512
513const README_CHAR_LIMIT: usize = 1000;
514
515fn truncate_readme(contents: &str) -> String {
516    let trimmed = contents.trim_end();
517    if trimmed.chars().count() <= README_CHAR_LIMIT {
518        return trimmed.to_owned();
519    }
520    let truncated: String = trimmed.chars().take(README_CHAR_LIMIT).collect();
521    format!("{truncated}\n\n[README truncated; showing first 1000 characters]")
522}
523
524fn readme_for_directory(directory: &Path) -> Result<Option<InstructionSource>, ContextError> {
525    let Some(directory_fd) = ContextDirectory::open(directory)
526        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
527    else {
528        return Ok(None);
529    };
530    let Some(file) = directory_fd
531        .open_instruction_file(OsStr::new("README.md"))
532        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
533    else {
534        return Ok(None);
535    };
536    let contents = read_open_file(file)
537        .map_err(|_error| ContextError::new("unable to read instruction context"))?;
538    Ok(Some(InstructionSource {
539        path: directory.join("README.md"),
540        contents: truncate_readme(&contents),
541    }))
542}
543
544fn discover_skills(
545    skills_root: &Path,
546    skills: &mut BTreeMap<String, SkillEntry>,
547) -> Result<(), ContextError> {
548    let Some(skills_parent_path) = skills_root.parent() else {
549        return Ok(());
550    };
551    let Some(skills_parent) = ContextDirectory::open(skills_parent_path)
552        .map_err(|_error| ContextError::new("unable to inspect skill context"))?
553    else {
554        return Ok(());
555    };
556    let Some(skills_name) = skills_root.file_name() else {
557        return Ok(());
558    };
559    let Some(skills_directory) = skills_parent
560        .open_child_directory(skills_name)
561        .map_err(|_error| ContextError::new("unable to inspect skill context"))?
562    else {
563        return Ok(());
564    };
565    discover_skill_directory(skills_root, &skills_directory, skills)
566}
567
568fn discover_skill_directory(
569    path: &Path,
570    directory: &ContextDirectory,
571    skills: &mut BTreeMap<String, SkillEntry>,
572) -> Result<(), ContextError> {
573    if let Some(file) = directory
574        .open_regular_file(OsStr::new("SKILL.md"))
575        .map_err(|_error| ContextError::new("unable to inspect skill context"))?
576    {
577        if let Ok(contents) = read_open_file(file) {
578            if let Some((name, description, model_invocable)) = parse_skill_frontmatter(&contents) {
579                skills.insert(
580                    name.clone(),
581                    SkillEntry {
582                        name,
583                        description,
584                        path: path.join("SKILL.md"),
585                        contents,
586                        model_invocable,
587                    },
588                );
589            }
590        }
591    }
592
593    let mut names = directory
594        .entries()
595        .map_err(|_error| ContextError::new("unable to inspect skill context"))?;
596    names.sort();
597    for name in names {
598        let Some(child) = directory
599            .open_child_directory(&name)
600            .map_err(|_error| ContextError::new("unable to inspect skill context"))?
601        else {
602            continue;
603        };
604        discover_skill_directory(&path.join(&name), &child, skills)?;
605    }
606    Ok(())
607}
608
609fn parse_skill_frontmatter(contents: &str) -> Option<(String, String, bool)> {
610    let lines = contents.lines().collect::<Vec<_>>();
611    if lines.first().map(|line| line.trim()) != Some("---") {
612        return None;
613    }
614    let end = lines
615        .iter()
616        .enumerate()
617        .skip(1)
618        .find(|(_, line)| line.trim() == "---")
619        .map(|(index, _)| index)?;
620
621    let mut name = None;
622    let mut description = None;
623    let mut model_invocable = true;
624    let mut index = 1;
625    while index < end {
626        let line = lines[index];
627        let trimmed = line.trim_start();
628        if let Some(value) = trimmed.strip_prefix("name:") {
629            name = parse_scalar(value);
630            index += 1;
631            continue;
632        }
633        if let Some(value) = trimmed.strip_prefix("disable-model-invocation:") {
634            model_invocable = !matches!(value.trim(), "true" | "True" | "TRUE");
635            index += 1;
636            continue;
637        }
638        if let Some(value) = trimmed.strip_prefix("description:") {
639            let value = value.trim();
640            if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
641                let folded = value.starts_with('>');
642                index += 1;
643                let mut block = Vec::new();
644                while index < end {
645                    let block_line = lines[index];
646                    if !block_line.trim().is_empty() && !block_line.starts_with(char::is_whitespace)
647                    {
648                        break;
649                    }
650                    block.push(block_line.trim().to_owned());
651                    index += 1;
652                }
653                description = Some(if folded {
654                    block.join(" ").trim().to_owned()
655                } else {
656                    block.join("\n").trim().to_owned()
657                });
658                continue;
659            }
660            description = parse_scalar(value);
661        }
662        index += 1;
663    }
664
665    let name = name?.trim().to_owned();
666    let description = description?.trim().to_owned();
667    if !valid_skill_name(&name) || description.is_empty() || description.chars().count() > 1024 {
668        return None;
669    }
670    Some((name, description, model_invocable))
671}
672
673fn valid_skill_name(name: &str) -> bool {
674    !name.is_empty()
675        && name.len() <= 64
676        && !name.starts_with('-')
677        && !name.ends_with('-')
678        && !name.contains("--")
679        && name
680            .bytes()
681            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
682}
683
684fn parse_scalar(value: &str) -> Option<String> {
685    let value = value.trim();
686    if value.is_empty() {
687        return None;
688    }
689    if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
690        return serde_json::from_str(value).ok();
691    }
692    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
693        return Some(value[1..value.len() - 1].replace("''", "'"));
694    }
695    Some(value.to_owned())
696}
697
698/// Keep metadata in the XML-shaped progressive-disclosure catalog from
699/// changing its structure. Full skill contents are intentionally not escaped:
700/// they are loaded only when a skill is selected as instructions.
701fn escape_xml(text: &str) -> String {
702    text.replace('&', "&amp;")
703        .replace('<', "&lt;")
704        .replace('>', "&gt;")
705        .replace('\"', "&quot;")
706        .replace('\'', "&apos;")
707}
708
709fn build_system_prompt(
710    configured_prompt: &str,
711    cwd: &Path,
712    instruction_files: &[InstructionSource],
713    readme_files: &[InstructionSource],
714    skills: &[SkillEntry],
715) -> String {
716    let mut sections = vec![configured_prompt.trim_end().to_owned()];
717    sections.push(format!("## Working directory\n{}", cwd.display()));
718    for instruction in instruction_files {
719        sections.push(format!(
720            "## Instructions from {}\n{}",
721            instruction.path.display(),
722            instruction.contents.trim_end()
723        ));
724    }
725    for readme in readme_files {
726        sections.push(format!(
727            "## README from {}\n{}",
728            readme.path.display(),
729            readme.contents.trim_end()
730        ));
731    }
732    let invocable_skills = skills
733        .iter()
734        .filter(|skill| skill.model_invocable)
735        .collect::<Vec<_>>();
736    if !invocable_skills.is_empty() {
737        let mut catalog = String::from("<available_skills>\n");
738        for skill in invocable_skills {
739            catalog.push_str(&format!(
740                "<skill>\n<name>{}</name>\n<description>{}</description>\n<location>{}</location>\n</skill>\n",
741                escape_xml(&skill.name),
742                escape_xml(&skill.description),
743                escape_xml(&skill.path.display().to_string())
744            ));
745        }
746        catalog.push_str("</available_skills>");
747        sections.push(catalog);
748    }
749    sections.join("\n\n")
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    #[cfg(unix)]
756    use std::os::unix::fs::symlink;
757    use std::sync::atomic::{AtomicU64, Ordering};
758    use std::time::{SystemTime, UNIX_EPOCH};
759
760    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
761
762    fn temporary_tree() -> (PathBuf, PathBuf) {
763        let home = loop {
764            let stamp = SystemTime::now()
765                .duration_since(UNIX_EPOCH)
766                .expect("clock")
767                .as_nanos();
768            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
769            let path = std::env::temp_dir().join(format!(
770                "lucy-context-{stamp}-{}-{counter}",
771                std::process::id()
772            ));
773            match fs::create_dir(&path) {
774                Ok(()) => break path,
775                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
776                Err(error) => panic!("temp tree: {error}"),
777            }
778        };
779        let home = fs::canonicalize(&home).expect("canonical temp tree");
780        let project = home.join("project").join("nested");
781        fs::create_dir_all(&project).expect("tree");
782        Command::new("git")
783            .arg("-C")
784            .arg(home.join("project"))
785            .args(["init", "-q"])
786            .output()
787            .expect("git init");
788        (home, project)
789    }
790
791    #[test]
792    fn context_uses_precedence_and_specific_skill_override() {
793        let (home, cwd) = temporary_tree();
794        let project = home.join("project");
795        fs::create_dir_all(config_dir(&home)).expect("global dir");
796        fs::write(config_dir(&home).join("CLAUDE.md"), "global claude").expect("global");
797        fs::write(config_dir(&home).join("AGENTS.md"), "global agents").expect("global agents");
798        fs::write(project.join("CLAUDE.md"), "root claude").expect("root claude");
799        fs::write(project.join("AGENTS.md"), "root agents").expect("root agents");
800        fs::write(cwd.join("CLAUDE.md"), "nested claude").expect("nested claude");
801
802        let global_skill = home.join(".agents/skills/shared/SKILL.md");
803        let root_skill = project.join(".agents/skills/shared/SKILL.md");
804        let nested_skill = cwd.join(".agents/skills/nested/SKILL.md");
805        fs::create_dir_all(global_skill.parent().expect("parent")).expect("global skills");
806        fs::create_dir_all(root_skill.parent().expect("parent")).expect("root skills");
807        fs::create_dir_all(nested_skill.parent().expect("parent")).expect("nested skills");
808        fs::write(
809            global_skill,
810            "---\nname: shared\ndescription: global description\n---\n# global",
811        )
812        .expect("global skill");
813        fs::write(
814            root_skill,
815            "---\nname: shared\ndescription: root description\n---\n# root",
816        )
817        .expect("root skill");
818        fs::write(
819            &nested_skill,
820            "---\nname: nested\ndescription: nested description\n---\n# nested",
821        )
822        .expect("nested skill");
823
824        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
825        assert_eq!(context.instruction_files.len(), 3);
826        assert_eq!(
827            context.instruction_files[0].path,
828            config_dir(&home).join("AGENTS.md")
829        );
830        assert!(context.instruction_files[0]
831            .contents
832            .contains("global agents"));
833        assert!(context.instruction_files[1]
834            .contents
835            .contains("root agents"));
836        assert!(context.instruction_files[2]
837            .contents
838            .contains("nested claude"));
839        assert!(!context.system_prompt.contains("root claude"));
840        assert!(context.system_prompt.contains("root description"));
841        assert!(!context.system_prompt.contains("global description"));
842        assert!(context.system_prompt.contains("nested description"));
843        assert!(context
844            .system_prompt
845            .contains(&nested_skill.display().to_string()));
846        assert!(!context.system_prompt.contains("# nested"));
847        assert!(context.system_prompt.contains("## Working directory"));
848        assert!(context
849            .system_prompt
850            .contains(&context.cwd.display().to_string()));
851
852        fs::remove_dir_all(home).expect("remove tree");
853    }
854
855    #[test]
856    fn context_failure_does_not_echo_a_secret_bearing_path() {
857        let (home, _cwd) = temporary_tree();
858        let missing = home.join("provider-secret-context-missing");
859        let error = resolve_boot_context(&home, &missing, "configured")
860            .expect_err("missing working directory");
861        let message = error.to_string();
862        assert!(message.contains("working directory"));
863        assert!(!message.contains("provider-secret"));
864        assert!(!message.contains(&missing.display().to_string()));
865        fs::remove_dir_all(home).expect("remove tree");
866    }
867
868    #[cfg(unix)]
869    #[test]
870    fn context_follows_symlinked_instruction_files_but_ignores_symlinked_skills() {
871        let (home, cwd) = temporary_tree();
872        let project = home.join("project");
873        fs::create_dir_all(config_dir(&home)).expect("global directory");
874        let global_instruction_target = home.join("global-instructions.md");
875        fs::write(&global_instruction_target, "symlinked global instructions")
876            .expect("global target");
877        symlink(
878            &global_instruction_target,
879            config_dir(&home).join("AGENTS.md"),
880        )
881        .expect("global instruction symlink");
882        fs::write(
883            config_dir(&home).join("CLAUDE.md"),
884            "real global instructions",
885        )
886        .expect("global fallback");
887
888        let project_instruction_target = home.join("project-instructions.md");
889        fs::write(
890            &project_instruction_target,
891            "symlinked project instructions",
892        )
893        .expect("project target");
894        symlink(&project_instruction_target, project.join("AGENTS.md"))
895            .expect("project agents symlink");
896        symlink(&project_instruction_target, project.join("CLAUDE.md"))
897            .expect("project claude symlink");
898
899        let global_skills = home.join(".agents/skills");
900        fs::create_dir_all(&global_skills).expect("global skills");
901        let linked_directory_target = home.join("linked-skill-directory");
902        fs::create_dir(&linked_directory_target).expect("linked directory target");
903        fs::write(
904            linked_directory_target.join("SKILL.md"),
905            "---\nname: linked-directory\ndescription: linked directory\n---\n",
906        )
907        .expect("linked directory skill");
908        symlink(
909            &linked_directory_target,
910            global_skills.join("linked-directory"),
911        )
912        .expect("skill directory symlink");
913
914        let linked_file_target = home.join("linked-skill-file.md");
915        fs::write(
916            &linked_file_target,
917            "---\nname: linked-file\ndescription: linked file\n---\n",
918        )
919        .expect("linked file target");
920        let linked_file_directory = global_skills.join("linked-file");
921        fs::create_dir(&linked_file_directory).expect("linked file directory");
922        symlink(&linked_file_target, linked_file_directory.join("SKILL.md"))
923            .expect("skill file symlink");
924
925        let valid_skill = global_skills.join("valid/SKILL.md");
926        fs::create_dir_all(valid_skill.parent().expect("valid skill parent"))
927            .expect("valid skill directory");
928        fs::write(
929            &valid_skill,
930            "---\nname: valid\ndescription: valid skill\n---\n",
931        )
932        .expect("valid skill");
933
934        let project_skill_target = home.join("project-skills");
935        let project_skill = project_skill_target.join("root-only/SKILL.md");
936        fs::create_dir_all(project_skill.parent().expect("project skill parent"))
937            .expect("project skill target");
938        fs::write(
939            &project_skill,
940            "---\nname: project-only\ndescription: project only\n---\n",
941        )
942        .expect("project skill");
943        fs::create_dir_all(project.join(".agents")).expect("project agents directory");
944        symlink(&project_skill_target, project.join(".agents/skills")).expect("skill root symlink");
945
946        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
947        assert_eq!(context.instruction_files.len(), 2);
948        assert_eq!(
949            context.instruction_files[0].path,
950            config_dir(&home).join("AGENTS.md")
951        );
952        assert_eq!(
953            context.instruction_files[0].contents,
954            "symlinked global instructions"
955        );
956        assert_eq!(context.instruction_files[1].path, project.join("AGENTS.md"));
957        assert_eq!(
958            context.instruction_files[1].contents,
959            "symlinked project instructions"
960        );
961        assert_eq!(context.skills.len(), 1);
962        assert_eq!(context.skills[0].name, "valid");
963        assert!(context
964            .system_prompt
965            .contains("symlinked global instructions"));
966        assert!(context
967            .system_prompt
968            .contains("symlinked project instructions"));
969        assert!(!context.system_prompt.contains("real global instructions"));
970        assert!(!context.system_prompt.contains("linked-directory"));
971        assert!(!context.system_prompt.contains("linked-file"));
972        assert!(!context.system_prompt.contains("project-only"));
973
974        fs::remove_dir_all(home).expect("remove tree");
975    }
976
977    #[cfg(unix)]
978    #[test]
979    fn context_ignores_symlinked_intermediate_parents() {
980        let (home, cwd) = temporary_tree();
981        let linked_home_target = home.join("linked-home-target");
982        fs::create_dir_all(linked_home_target.join(".config/lucy")).expect("linked Lucy directory");
983        fs::write(
984            linked_home_target.join(".config/lucy/AGENTS.md"),
985            "symlinked intermediate instructions",
986        )
987        .expect("linked instructions");
988        let linked_skill = linked_home_target.join(".agents/skills/linked/SKILL.md");
989        fs::create_dir_all(linked_skill.parent().expect("linked skill parent"))
990            .expect("linked skill directory");
991        fs::write(
992            &linked_skill,
993            "---\nname: linked-intermediate\ndescription: linked intermediate\n---\n",
994        )
995        .expect("linked skill");
996        let linked_home = home.join("linked-home");
997        symlink(&linked_home_target, &linked_home).expect("linked home");
998
999        let context = resolve_boot_context(&linked_home, &cwd, "configured").expect("context");
1000        assert!(context.instruction_files.is_empty());
1001        assert!(context.skills.is_empty());
1002        assert!(!context.system_prompt.contains("symlinked intermediate"));
1003        assert!(!context.system_prompt.contains("linked-intermediate"));
1004
1005        fs::remove_dir_all(home).expect("remove tree");
1006    }
1007
1008    #[test]
1009    fn skill_frontmatter_enforces_standard_names_and_hides_explicit_only_skills() {
1010        assert!(
1011            parse_skill_frontmatter("---\nname: valid-skill-2\ndescription: visible\n---\n")
1012                .is_some()
1013        );
1014        assert!(
1015            parse_skill_frontmatter("---\nname: Invalid_Skill\ndescription: invalid\n---\n")
1016                .is_none()
1017        );
1018        let hidden = SkillEntry {
1019            name: "private-skill".to_owned(),
1020            description: "hidden from automatic selection".to_owned(),
1021            path: PathBuf::from("/skills/private/SKILL.md"),
1022            contents: "instructions".to_owned(),
1023            model_invocable: false,
1024        };
1025        let prompt = build_system_prompt("configured", Path::new("/"), &[], &[], &[hidden]);
1026        assert!(!prompt.contains("private-skill"));
1027        assert_eq!(escape_xml("a<&>\"'"), "a&lt;&amp;&gt;&quot;&apos;");
1028    }
1029
1030    #[test]
1031    fn invalid_skill_metadata_is_skipped() {
1032        let (home, cwd) = temporary_tree();
1033        let invalid = cwd.join(".agents/skills/invalid/SKILL.md");
1034        fs::create_dir_all(invalid.parent().expect("parent")).expect("skill dir");
1035        fs::write(invalid, "---\nname: invalid\n---\nbody").expect("skill");
1036        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1037        assert!(context.skills.is_empty());
1038        assert!(!context.system_prompt.contains("invalid"));
1039        fs::remove_dir_all(home).expect("remove tree");
1040    }
1041
1042    #[test]
1043    fn system_prompt_includes_cwd() {
1044        let (home, cwd) = temporary_tree();
1045        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1046        assert!(context.system_prompt.contains("## Working directory"));
1047        assert!(context
1048            .system_prompt
1049            .contains(&context.cwd.display().to_string()));
1050        fs::remove_dir_all(home).expect("remove tree");
1051    }
1052
1053    #[test]
1054    fn readme_full_content_in_system_prompt() {
1055        let (home, cwd) = temporary_tree();
1056        fs::write(cwd.join("README.md"), "# Project\n\nShort readme.").expect("readme");
1057        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1058        assert!(context.system_prompt.contains("## README from"));
1059        assert!(context.system_prompt.contains("# Project"));
1060        assert!(context.system_prompt.contains("Short readme."));
1061        assert!(!context.system_prompt.contains("[README truncated"));
1062        fs::remove_dir_all(home).expect("remove tree");
1063    }
1064
1065    #[test]
1066    fn readme_truncated_when_too_long() {
1067        let (home, cwd) = temporary_tree();
1068        let content = "a".repeat(1000) + "b";
1069        fs::write(cwd.join("README.md"), &content).expect("readme");
1070        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1071        assert!(context
1072            .system_prompt
1073            .contains("[README truncated; showing first 1000 characters]"));
1074        fs::remove_dir_all(home).expect("remove tree");
1075    }
1076
1077    #[test]
1078    fn readme_from_multiple_ancestor_directories() {
1079        let (home, cwd) = temporary_tree();
1080        let project = home.join("project");
1081        fs::write(project.join("README.md"), "root readme").expect("root readme");
1082        fs::write(cwd.join("README.md"), "nested readme").expect("nested readme");
1083        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1084        assert!(context.system_prompt.contains("root readme"));
1085        assert!(context.system_prompt.contains("nested readme"));
1086        fs::remove_dir_all(home).expect("remove tree");
1087    }
1088
1089    #[test]
1090    fn no_readme_works_without_error() {
1091        let (home, cwd) = temporary_tree();
1092        let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
1093        assert!(!context.system_prompt.contains("## README from"));
1094        fs::remove_dir_all(home).expect("remove tree");
1095    }
1096}