Skip to main content

canic_host/config_discovery/
mod.rs

1//! Module: config_discovery
2//!
3//! Responsibility: discover current Canic App configuration files in one workspace.
4//! Does not own: Fleet convergence, prompting, reviewed plans, or runtime state.
5//! Boundary: returns canonical local paths and rejects duplicate App identities.
6
7use crate::release_set::{WorkspaceDiscoveryError, read_app_config_identity};
8use std::{
9    collections::BTreeMap,
10    env, fs, io,
11    path::{Path, PathBuf},
12};
13use thiserror::Error as ThisError;
14
15const APPS_ROOT: &str = "apps";
16const ICP_CONFIG_FILE: &str = "icp.yaml";
17
18/// Typed failure while discovering current App configuration.
19
20#[derive(Debug, ThisError)]
21pub enum ConfigDiscoveryError {
22    #[error("failed to resolve current directory: {0}")]
23    CurrentDirectory(#[source] io::Error),
24
25    #[error("failed to canonicalize Canic workspace path {path}: {source}")]
26    Canonicalize {
27        path: PathBuf,
28        #[source]
29        source: io::Error,
30    },
31
32    #[error("failed to read Canic config directory {path}: {source}")]
33    Directory {
34        path: PathBuf,
35        #[source]
36        source: io::Error,
37    },
38
39    #[error("failed to inspect Canic config path {path}: {source}")]
40    Path {
41        path: PathBuf,
42        #[source]
43        source: io::Error,
44    },
45
46    #[error("multiple configs declare app {app}: {configs}")]
47    DuplicateApp { app: String, configs: String },
48
49    #[error(transparent)]
50    WorkspaceDiscovery(#[from] WorkspaceDiscoveryError),
51}
52
53/// Resolve the operator-facing Canic workspace root from the current directory.
54pub fn current_canic_workspace_root() -> Result<PathBuf, ConfigDiscoveryError> {
55    let current_dir = env::current_dir().map_err(ConfigDiscoveryError::CurrentDirectory)?;
56    let current_dir =
57        current_dir
58            .canonicalize()
59            .map_err(|source| ConfigDiscoveryError::Canonicalize {
60                path: current_dir,
61                source,
62            })?;
63    Ok(discover_canic_workspace_root_from(&current_dir)?.unwrap_or(current_dir))
64}
65
66/// Find the nearest conventional Canic workspace from one start path.
67pub fn discover_canic_workspace_root_from(
68    start: &Path,
69) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
70    let mut nearest_apps_root = None;
71    for candidate in start.ancestors() {
72        if !discover_workspace_canic_config_choices(candidate)?.is_empty() {
73            let root =
74                candidate
75                    .canonicalize()
76                    .map_err(|source| ConfigDiscoveryError::Canonicalize {
77                        path: candidate.to_path_buf(),
78                        source,
79                    })?;
80            if candidate.join(ICP_CONFIG_FILE).is_file() {
81                return Ok(Some(root));
82            }
83            if nearest_apps_root.is_none() {
84                nearest_apps_root = Some(root);
85            }
86        }
87    }
88    Ok(nearest_apps_root)
89}
90
91/// Discover App configuration choices in the current workspace.
92pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
93    let workspace_root = current_canic_workspace_root()?;
94    discover_workspace_canic_config_choices(&workspace_root)
95}
96
97/// Discover candidate `canic.toml` files under conventional App roots.
98pub fn discover_workspace_canic_config_choices(
99    workspace_root: &Path,
100) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
101    let mut choices = Vec::new();
102    for root in workspace_app_roots(workspace_root) {
103        collect_canic_config_choices(&root, &mut choices)?;
104    }
105    choices.sort();
106    choices.dedup();
107    reject_duplicate_app_names(&choices)?;
108    Ok(choices)
109}
110
111/// Discover candidate `canic.toml` files under one App root.
112pub fn discover_canic_config_choices(root: &Path) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
113    let mut choices = Vec::new();
114    collect_canic_config_choices(root, &mut choices)?;
115    choices.sort();
116    reject_duplicate_app_names(&choices)?;
117    Ok(choices)
118}
119
120/// Return conventional App roots for one workspace.
121#[must_use]
122pub fn workspace_app_roots(workspace_root: &Path) -> Vec<PathBuf> {
123    vec![workspace_root.join(APPS_ROOT)]
124}
125
126/// Select one exact discovered config by its declared App identity.
127pub fn select_discovered_app_config_path(
128    choices: &[PathBuf],
129    app: &str,
130) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
131    Ok(unique_configs_by_app(choices)?.remove(app))
132}
133
134fn reject_duplicate_app_names(choices: &[PathBuf]) -> Result<(), ConfigDiscoveryError> {
135    let _ = unique_configs_by_app(choices)?;
136    Ok(())
137}
138
139fn unique_configs_by_app(
140    choices: &[PathBuf],
141) -> Result<BTreeMap<String, PathBuf>, ConfigDiscoveryError> {
142    let mut by_app = BTreeMap::<String, Vec<&PathBuf>>::new();
143    for path in choices {
144        if let Ok(app) = read_app_config_identity(path) {
145            by_app.entry(app).or_default().push(path);
146        }
147    }
148    for (app, paths) in &by_app {
149        if paths.len() > 1 {
150            return Err(ConfigDiscoveryError::DuplicateApp {
151                app: app.clone(),
152                configs: paths
153                    .iter()
154                    .map(|path| path.display().to_string())
155                    .collect::<Vec<_>>()
156                    .join(", "),
157            });
158        }
159    }
160    Ok(by_app
161        .into_iter()
162        .filter_map(|(app, mut paths)| paths.pop().cloned().map(|path| (app, path)))
163        .collect())
164}
165
166fn collect_canic_config_choices(
167    root: &Path,
168    choices: &mut Vec<PathBuf>,
169) -> Result<(), ConfigDiscoveryError> {
170    if !root.is_dir() {
171        return Ok(());
172    }
173    for entry in fs::read_dir(root).map_err(|source| ConfigDiscoveryError::Directory {
174        path: root.to_path_buf(),
175        source,
176    })? {
177        let entry = entry.map_err(|source| ConfigDiscoveryError::Directory {
178            path: root.to_path_buf(),
179            source,
180        })?;
181        let path = entry.path();
182        let file_type = entry
183            .file_type()
184            .map_err(|source| ConfigDiscoveryError::Path {
185                path: path.clone(),
186                source,
187            })?;
188        if file_type.is_symlink() {
189            continue;
190        }
191        if file_type.is_dir() {
192            collect_canic_config_choices(&path, choices)?;
193        } else if file_type.is_file()
194            && path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
195        {
196            choices.push(path);
197        }
198    }
199    Ok(())
200}