aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/skills/walker.rs
blob: b93845f91b8e6535c5be5a51d99256145bf7dc1e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
//! Filesystem discovery for `SKILL.md` files.
//!
//! Recursively scans `.atuin/skills/` directories at the project and global
//! levels. Supports nested directories for organization (e.g.
//! `.atuin/skills/ops/deploy/SKILL.md`).

use std::path::{Path, PathBuf};

const SKILL_FILENAME: &str = "SKILL.md";

/// A skill file found on disk, before body interpolation.
#[derive(Debug)]
pub(crate) struct RawSkillFile {
    /// Full path to the SKILL.md file.
    pub path: PathBuf,
    /// The parent directory name, used as fallback skill name.
    pub dir_name: String,
    /// Whether this is a project-level skill (vs global).
    #[allow(dead_code)]
    pub is_project: bool,
    /// Raw file content.
    pub content: String,
}

/// Discover all `SKILL.md` files across project and global skill directories.
///
/// Project skills come first in the returned list (higher priority for
/// deduplication).
pub(crate) async fn discover(
    project_skills_dir: Option<&Path>,
    global_skills_dir: &Path,
) -> Vec<RawSkillFile> {
    let mut files = Vec::new();

    // Project skills first (higher priority)
    if let Some(dir) = project_skills_dir.filter(|d| d.is_dir()) {
        scan_dir(dir, true, &mut files).await;
    }

    // Global skills second
    if global_skills_dir.is_dir() {
        scan_dir(global_skills_dir, false, &mut files).await;
    }

    files
}

/// The default global skills directory (`~/.config/atuin/skills/`).
pub(crate) fn global_skills_dir() -> PathBuf {
    atuin_common::utils::config_dir().join("skills")
}

/// Given a project working directory, return the project skills directory.
pub(crate) fn project_skills_dir(project_root: &Path) -> PathBuf {
    project_root.join(".atuin").join("skills")
}

/// Recursively scan a directory for `SKILL.md` files.
async fn scan_dir(dir: &Path, is_project: bool, out: &mut Vec<RawSkillFile>) {
    let mut entries = match tokio::fs::read_dir(dir).await {
        Ok(entries) => entries,
        Err(e) => {
            tracing::debug!("Could not read skills directory {}: {e}", dir.display());
            return;
        }
    };

    let mut subdirs = Vec::new();

    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();

        if path.is_dir() {
            // Check for SKILL.md directly in this directory
            let skill_path = path.join(SKILL_FILENAME);
            if skill_path.is_file() {
                let dir_name = path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("unknown")
                    .to_string();

                match tokio::fs::read_to_string(&skill_path).await {
                    Ok(content) => {
                        out.push(RawSkillFile {
                            path: skill_path,
                            dir_name,
                            is_project,
                            content,
                        });
                    }
                    Err(e) => {
                        tracing::warn!("Failed to read skill file {}: {e}", skill_path.display());
                    }
                }
            }

            // Collect subdirectories for recursive scanning
            subdirs.push(path);
        }
    }

    for subdir in subdirs {
        Box::pin(scan_dir(&subdir, is_project, out)).await;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn setup_skill(dir: &Path, rel_path: &str, content: &str) {
        let skill_dir = dir.join(rel_path);
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
    }

    #[tokio::test]
    async fn discovers_project_skills() {
        let dir = tempfile::tempdir().unwrap();
        let skills_dir = dir.path().join("skills");
        setup_skill(&skills_dir, "deploy", "---\nname: deploy\n---\nDeploy.");

        let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await;
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].dir_name, "deploy");
        assert!(files[0].is_project);
    }

    #[tokio::test]
    async fn discovers_global_skills() {
        let dir = tempfile::tempdir().unwrap();
        let skills_dir = dir.path().join("skills");
        setup_skill(&skills_dir, "review", "---\nname: review\n---\nReview.");

        let files = discover(None, &skills_dir).await;
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].dir_name, "review");
        assert!(!files[0].is_project);
    }

    #[tokio::test]
    async fn discovers_nested_skills() {
        let dir = tempfile::tempdir().unwrap();
        let skills_dir = dir.path().join("skills");
        setup_skill(&skills_dir, "ops/deploy", "---\nname: deploy\n---\n");
        setup_skill(&skills_dir, "ops/rollback", "---\nname: rollback\n---\n");

        let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await;
        assert_eq!(files.len(), 2);
    }

    #[tokio::test]
    async fn project_comes_before_global() {
        let project = tempfile::tempdir().unwrap();
        let global = tempfile::tempdir().unwrap();
        let project_skills = project.path().join("skills");
        let global_skills = global.path().join("skills");

        setup_skill(&project_skills, "a-skill", "project");
        setup_skill(&global_skills, "b-skill", "global");

        let files = discover(Some(&project_skills), &global_skills).await;
        assert_eq!(files.len(), 2);
        assert!(files[0].is_project);
        assert!(!files[1].is_project);
    }

    #[tokio::test]
    async fn missing_directories_handled() {
        let files = discover(
            Some(Path::new("/does/not/exist")),
            Path::new("/also/missing"),
        )
        .await;
        assert!(files.is_empty());
    }
}