atman_runtime/tools/
flow_source.rs1use 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}