canic_host/config_discovery/
mod.rs1use 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#[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
53pub 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(¤t_dir)?.unwrap_or(current_dir))
64}
65
66pub 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
91pub 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
97pub 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
111pub 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#[must_use]
122pub fn workspace_app_roots(workspace_root: &Path) -> Vec<PathBuf> {
123 vec![workspace_root.join(APPS_ROOT)]
124}
125
126pub 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}