Skip to main content

atman_runtime/tools/
flow_source.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::tool::ToolCtx;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FlowSourceScope {
8    Project,
9    User,
10}
11
12impl FlowSourceScope {
13    pub fn as_str(self) -> &'static str {
14        match self {
15            Self::Project => "project",
16            Self::User => "user",
17        }
18    }
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct InstalledFlowSource {
23    pub path: PathBuf,
24    pub scope: FlowSourceScope,
25}
26
27pub fn current_project_root() -> Option<PathBuf> {
28    let cwd = std::env::current_dir().ok()?;
29    crate::session_meta::find_project_root(&cwd)
30}
31
32pub fn project_root_for_ctx(ctx: &ToolCtx) -> Option<PathBuf> {
33    ctx.workspace
34        .as_ref()
35        .map(|workspace| workspace.path.clone())
36        .or_else(current_project_root)
37}
38
39pub fn installed_sources(
40    config_dir: Option<&Path>,
41    project_root: Option<&Path>,
42) -> Vec<InstalledFlowSource> {
43    let dirs = [
44        project_root.map(|root| (FlowSourceScope::Project, root.join(".atman/commands"))),
45        config_dir.map(|root| (FlowSourceScope::User, root.join("commands"))),
46    ];
47    let mut seen = HashSet::new();
48    let mut sources = Vec::new();
49    for (scope, dir) in dirs.into_iter().flatten() {
50        let Ok(entries) = std::fs::read_dir(dir) else {
51            continue;
52        };
53        let mut paths = entries
54            .flatten()
55            .map(|entry| entry.path())
56            .filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("at"))
57            .collect::<Vec<_>>();
58        paths.sort();
59        for path in paths {
60            let Some(name) = path.file_name().map(|name| name.to_os_string()) else {
61                continue;
62            };
63            if seen.insert(name) {
64                sources.push(InstalledFlowSource { path, scope });
65            }
66        }
67    }
68    sources
69}
70
71pub fn resolve_installed_command(
72    name: &str,
73    config_dir: Option<&Path>,
74    project_root: Option<&Path>,
75) -> Option<InstalledFlowSource> {
76    let file_name = format!("{name}.at");
77    installed_sources(config_dir, project_root)
78        .into_iter()
79        .find(|source| source.path.file_name().and_then(|name| name.to_str()) == Some(&file_name))
80}
81
82pub fn candidates(flow_ref: &str, ctx: &ToolCtx) -> Vec<PathBuf> {
83    let config_dir = crate::storage::config_dir().ok();
84    let project_root = project_root_for_ctx(ctx);
85    let cwd = std::env::current_dir().ok();
86    candidates_from(
87        flow_ref,
88        config_dir.as_deref(),
89        project_root.as_deref(),
90        cwd.as_deref(),
91    )
92}
93
94pub fn candidates_from(
95    flow_ref: &str,
96    config_dir: Option<&Path>,
97    project_root: Option<&Path>,
98    cwd: Option<&Path>,
99) -> Vec<PathBuf> {
100    let path = PathBuf::from(flow_ref);
101    if path.is_absolute() || path.components().count() > 1 || flow_ref.starts_with('.') {
102        return vec![match (path.is_absolute(), cwd) {
103            (false, Some(cwd)) => cwd.join(path),
104            _ => path,
105        }];
106    }
107    let file_name = if flow_ref.ends_with(".at") {
108        flow_ref.to_string()
109    } else {
110        format!("{flow_ref}.at")
111    };
112    let mut candidates = Vec::new();
113    if let Some(project_root) = project_root {
114        candidates.push(project_root.join(".atman/commands").join(&file_name));
115    }
116    if let Some(config_dir) = config_dir {
117        candidates.push(config_dir.join("commands").join(&file_name));
118    }
119    if let Some(cwd) = cwd {
120        candidates.push(cwd.join(file_name));
121    } else {
122        candidates.push(PathBuf::from(file_name));
123    }
124    candidates
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn project_sources_shadow_user_sources_by_file_name() {
133        let root = tempfile::tempdir().unwrap();
134        let project = root.path().join("project");
135        let config = root.path().join("config");
136        std::fs::create_dir_all(project.join(".atman/commands")).unwrap();
137        std::fs::create_dir_all(config.join("commands")).unwrap();
138        std::fs::write(project.join(".atman/commands/review.at"), "project").unwrap();
139        std::fs::write(config.join("commands/review.at"), "user").unwrap();
140        std::fs::write(config.join("commands/agent.at"), "user").unwrap();
141
142        let sources = installed_sources(Some(&config), Some(&project));
143
144        assert_eq!(sources.len(), 2);
145        assert!(sources.iter().any(|source| {
146            source.scope == FlowSourceScope::Project
147                && source.path == project.join(".atman/commands/review.at")
148        }));
149        assert!(
150            !sources
151                .iter()
152                .any(|source| source.path == config.join("commands/review.at"))
153        );
154    }
155
156    #[test]
157    fn explicit_relative_path_is_not_shadowed_by_catalogs() {
158        let paths = candidates_from(
159            "./flows/review.at",
160            Some(Path::new("/config")),
161            Some(Path::new("/project")),
162            Some(Path::new("/cwd")),
163        );
164        assert_eq!(paths, [PathBuf::from("/cwd/./flows/review.at")]);
165    }
166
167    #[test]
168    fn named_flow_uses_project_user_then_cwd_precedence() {
169        let paths = candidates_from(
170            "review",
171            Some(Path::new("/config")),
172            Some(Path::new("/project")),
173            Some(Path::new("/cwd")),
174        );
175        assert_eq!(
176            paths,
177            [
178                PathBuf::from("/project/.atman/commands/review.at"),
179                PathBuf::from("/config/commands/review.at"),
180                PathBuf::from("/cwd/review.at"),
181            ]
182        );
183    }
184}