fleetflow_atom/
discovery.rs

1//! ファイル自動発見機能
2//!
3//! 規約ベースのディレクトリ構造からKDLファイルを自動的に発見します。
4
5use crate::error::{FlowError, Result};
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use tracing::{debug, info, warn};
9
10/// 発見されたファイル群
11#[derive(Debug, Clone, Default)]
12pub struct DiscoveredFiles {
13    /// ルートファイル (flow.kdl)
14    pub root: Option<PathBuf>,
15    /// サービス定義ファイル (services/**/*.kdl)
16    pub services: Vec<PathBuf>,
17    /// ステージ定義ファイル (stages/**/*.kdl)
18    pub stages: Vec<PathBuf>,
19    /// 変数定義ファイル (variables/**/*.kdl)
20    pub variables: Vec<PathBuf>,
21    /// ローカルオーバーライドファイル (flow.local.kdl)
22    pub local_override: Option<PathBuf>,
23}
24
25/// プロジェクトルートを検出
26///
27/// 以下の優先順位で検索:
28/// 1. 環境変数 FLOW_PROJECT_ROOT
29/// 2. カレントディレクトリから上に向かって flow.kdl を探す
30#[tracing::instrument]
31pub fn find_project_root() -> Result<PathBuf> {
32    // 1. 環境変数
33    if let Ok(root) = std::env::var("FLOW_PROJECT_ROOT") {
34        let path = PathBuf::from(&root);
35        debug!(env_root = %root, "Checking FLOW_PROJECT_ROOT");
36        if path.join("flow.kdl").exists() {
37            info!(project_root = %path.display(), "Found project root from environment variable");
38            return Ok(path);
39        }
40    }
41
42    // 2. カレントディレクトリから上に向かって探す
43    let start_dir = std::env::current_dir()?;
44    let mut current = start_dir.clone();
45    debug!(start_dir = %start_dir.display(), "Searching for project root");
46
47    loop {
48        let flow_file = current.join("flow.kdl");
49        debug!(checking = %current.display(), "Looking for flow.kdl");
50        if flow_file.exists() {
51            info!(project_root = %current.display(), "Found project root");
52            return Ok(current);
53        }
54
55        // 親ディレクトリへ
56        if !current.pop() {
57            break;
58        }
59    }
60
61    warn!(start_dir = %start_dir.display(), "Project root not found");
62    Err(FlowError::ProjectRootNotFound(start_dir))
63}
64
65/// プロジェクトルートからファイルを自動発見
66#[tracing::instrument(skip(project_root), fields(project_root = %project_root.display()))]
67pub fn discover_files(project_root: &Path) -> Result<DiscoveredFiles> {
68    debug!("Starting file discovery");
69    let mut discovered = DiscoveredFiles::default();
70
71    // flow.kdl
72    let root_file = project_root.join("flow.kdl");
73    if root_file.exists() {
74        debug!(file = %root_file.display(), "Found root file");
75        discovered.root = Some(root_file);
76    }
77
78    // services/**/*.kdl
79    let services_dir = project_root.join("services");
80    if services_dir.is_dir() {
81        discovered.services = discover_kdl_files(&services_dir)?;
82        info!(
83            service_count = discovered.services.len(),
84            "Discovered service files"
85        );
86    }
87
88    // stages/**/*.kdl
89    let stages_dir = project_root.join("stages");
90    if stages_dir.is_dir() {
91        discovered.stages = discover_kdl_files(&stages_dir)?;
92        info!(
93            stage_count = discovered.stages.len(),
94            "Discovered stage files"
95        );
96    }
97
98    // variables/**/*.kdl
99    let variables_dir = project_root.join("variables");
100    if variables_dir.is_dir() {
101        discovered.variables = discover_kdl_files(&variables_dir)?;
102        info!(
103            variable_count = discovered.variables.len(),
104            "Discovered variable files"
105        );
106    }
107
108    // flow.local.kdl
109    let local_override = project_root.join("flow.local.kdl");
110    if local_override.exists() {
111        discovered.local_override = Some(local_override);
112    }
113
114    Ok(discovered)
115}
116
117/// ディレクトリ配下の .kdl ファイルを再帰的に発見
118///
119/// アルファベット順にソートして返す
120fn discover_kdl_files(dir: &Path) -> Result<Vec<PathBuf>> {
121    let mut files = Vec::new();
122    let mut visited = HashSet::new();
123
124    visit_dir(dir, &mut files, &mut visited)?;
125
126    // アルファベット順にソート
127    files.sort();
128
129    Ok(files)
130}
131
132/// ディレクトリを再帰的に走査
133fn visit_dir(dir: &Path, files: &mut Vec<PathBuf>, visited: &mut HashSet<PathBuf>) -> Result<()> {
134    if !dir.is_dir() {
135        return Ok(());
136    }
137
138    // 正規化されたパスを取得してループを検出
139    let canonical_dir = dir.canonicalize().map_err(|e| FlowError::DiscoveryError {
140        path: dir.to_path_buf(),
141        message: format!("パスの正規化に失敗: {}", e),
142    })?;
143
144    // ループ検出: 既に訪問済みなら終了
145    if visited.contains(&canonical_dir) {
146        warn!(dir = %canonical_dir.display(), "Symlink loop detected, skipping");
147        return Ok(());
148    }
149
150    // 訪問済みとしてマーク
151    visited.insert(canonical_dir.clone());
152
153    let entries = std::fs::read_dir(dir).map_err(|e| FlowError::DiscoveryError {
154        path: dir.to_path_buf(),
155        message: format!("ディレクトリの読み込みに失敗: {}", e),
156    })?;
157
158    for entry in entries {
159        let entry = entry.map_err(|e| FlowError::DiscoveryError {
160            path: dir.to_path_buf(),
161            message: format!("ディレクトリエントリの読み込みに失敗: {}", e),
162        })?;
163        let path = entry.path();
164
165        if path.is_dir() {
166            // 再帰的に探索
167            visit_dir(&path, files, visited)?;
168        } else if path.extension().and_then(|s| s.to_str()) == Some("kdl") {
169            files.push(path);
170        }
171    }
172
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::fs;
180
181    fn create_test_project(base: &Path) -> Result<()> {
182        // flow.kdl
183        fs::write(base.join("flow.kdl"), "// root")?;
184
185        // services/
186        fs::create_dir_all(base.join("services"))?;
187        fs::write(base.join("services/api.kdl"), "service \"api\" {}")?;
188        fs::write(
189            base.join("services/postgres.kdl"),
190            "service \"postgres\" {}",
191        )?;
192
193        // services/backend/
194        fs::create_dir_all(base.join("services/backend"))?;
195        fs::write(
196            base.join("services/backend/worker.kdl"),
197            "service \"worker\" {}",
198        )?;
199
200        // stages/
201        fs::create_dir_all(base.join("stages"))?;
202        fs::write(base.join("stages/local.kdl"), "stage \"local\" {}")?;
203        fs::write(base.join("stages/prod.kdl"), "stage \"prod\" {}")?;
204
205        // variables/
206        fs::create_dir_all(base.join("variables"))?;
207        fs::write(base.join("variables/common.kdl"), "variables {}")?;
208
209        // flow.local.kdl
210        fs::write(base.join("flow.local.kdl"), "// local override")?;
211
212        Ok(())
213    }
214
215    #[test]
216    fn test_discover_files() -> Result<()> {
217        let temp_dir = tempfile::tempdir().unwrap();
218        let project_root = temp_dir.path();
219
220        create_test_project(project_root)?;
221
222        let discovered = discover_files(project_root)?;
223
224        // flow.kdl
225        assert!(discovered.root.is_some());
226
227        // services
228        assert_eq!(discovered.services.len(), 3);
229        assert!(discovered.services[0].ends_with("services/api.kdl"));
230        assert!(discovered.services[1].ends_with("services/backend/worker.kdl"));
231        assert!(discovered.services[2].ends_with("services/postgres.kdl"));
232
233        // stages
234        assert_eq!(discovered.stages.len(), 2);
235        assert!(discovered.stages[0].ends_with("stages/local.kdl"));
236        assert!(discovered.stages[1].ends_with("stages/prod.kdl"));
237
238        // variables
239        assert_eq!(discovered.variables.len(), 1);
240        assert!(discovered.variables[0].ends_with("variables/common.kdl"));
241
242        // flow.local.kdl
243        assert!(discovered.local_override.is_some());
244
245        Ok(())
246    }
247
248    #[test]
249    fn test_discover_files_minimal() -> Result<()> {
250        let temp_dir = tempfile::tempdir().unwrap();
251        let project_root = temp_dir.path();
252
253        // 最小構成: flow.kdl のみ
254        fs::write(project_root.join("flow.kdl"), "// root")?;
255
256        let discovered = discover_files(project_root)?;
257
258        assert!(discovered.root.is_some());
259        assert_eq!(discovered.services.len(), 0);
260        assert_eq!(discovered.stages.len(), 0);
261        assert_eq!(discovered.variables.len(), 0);
262        assert!(discovered.local_override.is_none());
263
264        Ok(())
265    }
266
267    #[test]
268    fn test_alphabetical_order() -> Result<()> {
269        let temp_dir = tempfile::tempdir().unwrap();
270        let project_root = temp_dir.path();
271
272        fs::write(project_root.join("flow.kdl"), "// root")?;
273        fs::create_dir_all(project_root.join("services"))?;
274
275        // アルファベット順ではない順序で作成
276        fs::write(project_root.join("services/zebra.kdl"), "")?;
277        fs::write(project_root.join("services/alpha.kdl"), "")?;
278        fs::write(project_root.join("services/beta.kdl"), "")?;
279
280        let discovered = discover_files(project_root)?;
281
282        // アルファベット順にソートされていることを確認
283        assert!(discovered.services[0].ends_with("services/alpha.kdl"));
284        assert!(discovered.services[1].ends_with("services/beta.kdl"));
285        assert!(discovered.services[2].ends_with("services/zebra.kdl"));
286
287        Ok(())
288    }
289}