1use crate::release_set::{
2 WorkspaceDiscoveryError, configured_deployable_roles, configured_fleet_name,
3};
4use crate::table::{ColumnAlign, render_table};
5use crate::workspace_discovery::normalize_workspace_path;
6use std::{
7 collections::BTreeMap,
8 env, fs,
9 io::{self, IsTerminal, Write},
10 path::{Path, PathBuf},
11};
12use thiserror::Error as ThisError;
13
14struct ConfigChoiceRow {
19 option: String,
20 config: String,
21 canisters: String,
22}
23
24const CONFIG_CHOICE_ROLE_PREVIEW_LIMIT: usize = 6;
25const FLEETS_ROOT: &str = "fleets";
26const ICP_CONFIG_FILE: &str = "icp.yaml";
27const ROOT_CONFIG_RELATIVE: &str = "canic.toml";
28
29#[derive(Debug, ThisError)]
34pub enum ConfigDiscoveryError {
35 #[error("failed to resolve current directory: {0}")]
36 CurrentDirectory(#[source] io::Error),
37
38 #[error("failed to canonicalize Canic project path {path}: {source}")]
39 Canonicalize {
40 path: PathBuf,
41 #[source]
42 source: io::Error,
43 },
44
45 #[error("failed to read Canic config directory {path}: {source}")]
46 Directory {
47 path: PathBuf,
48 #[source]
49 source: io::Error,
50 },
51
52 #[error("failed to inspect Canic config path {path}: {source}")]
53 Path {
54 path: PathBuf,
55 #[source]
56 source: io::Error,
57 },
58
59 #[error("multiple configs declare fleet {fleet}: {configs}")]
60 DuplicateFleet { fleet: String, configs: String },
61
62 #[error(transparent)]
63 WorkspaceDiscovery(#[from] WorkspaceDiscoveryError),
64}
65
66pub fn current_canic_project_root() -> Result<PathBuf, ConfigDiscoveryError> {
68 let current_dir = env::current_dir().map_err(ConfigDiscoveryError::CurrentDirectory)?;
69 let current_dir =
70 current_dir
71 .canonicalize()
72 .map_err(|source| ConfigDiscoveryError::Canonicalize {
73 path: current_dir,
74 source,
75 })?;
76 Ok(discover_canic_project_root_from(¤t_dir)?.unwrap_or(current_dir))
77}
78
79pub fn discover_canic_project_root_from(
80 start: &Path,
81) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
82 let mut nearest_fleets_root = None;
83 for candidate in start.ancestors() {
84 if !discover_project_canic_config_choices(candidate)?.is_empty() {
85 let root =
86 candidate
87 .canonicalize()
88 .map_err(|source| ConfigDiscoveryError::Canonicalize {
89 path: candidate.to_path_buf(),
90 source,
91 })?;
92 if candidate.join(ICP_CONFIG_FILE).is_file() {
93 return Ok(Some(root));
94 }
95 if nearest_fleets_root.is_none() {
96 nearest_fleets_root = Some(root);
97 }
98 }
99 }
100 Ok(nearest_fleets_root)
101}
102
103pub(super) fn resolve_install_config_path(
105 workspace_root: &Path,
106 explicit_config_path: Option<&str>,
107 interactive: bool,
108) -> Result<PathBuf, Box<dyn std::error::Error>> {
109 if let Some(path) = explicit_config_path {
110 return Ok(normalize_workspace_path(
111 workspace_root,
112 PathBuf::from(path),
113 ));
114 }
115
116 let default = workspace_root.join(FLEETS_ROOT).join(ROOT_CONFIG_RELATIVE);
117 if default.is_file() {
118 return Ok(default);
119 }
120
121 let choices = discover_workspace_canic_config_choices(workspace_root)?;
122 if interactive
123 && let Some(path) = prompt_install_config_choice(workspace_root, &default, &choices)?
124 {
125 return Ok(path);
126 }
127
128 Err(config_selection_error(workspace_root, &default, &choices).into())
129}
130
131pub(super) fn discover_workspace_canic_config_choices(
133 workspace_root: &Path,
134) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
135 discover_project_canic_config_choices(workspace_root)
136}
137
138pub fn discover_project_canic_config_choices(
140 project_root: &Path,
141) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
142 let mut choices = Vec::new();
143 for root in project_fleet_roots(project_root) {
144 collect_canic_config_choices(&root, &mut choices)?;
145 }
146 choices.sort();
147 choices.dedup();
148 reject_duplicate_fleet_names(&choices)?;
149 Ok(choices)
150}
151
152pub fn discover_canic_config_choices(root: &Path) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
154 let mut choices = Vec::new();
155 collect_canic_config_choices(root, &mut choices)?;
156 choices.sort();
157 reject_duplicate_fleet_names(&choices)?;
158 Ok(choices)
159}
160
161#[must_use]
162pub fn project_fleet_roots(project_root: &Path) -> Vec<PathBuf> {
163 vec![project_root.join(FLEETS_ROOT)]
164}
165
166fn reject_duplicate_fleet_names(choices: &[PathBuf]) -> Result<(), ConfigDiscoveryError> {
167 let _ = unique_configs_by_fleet(choices)?;
168 Ok(())
169}
170
171pub fn select_discovered_fleet_config_path(
173 choices: &[PathBuf],
174 fleet: &str,
175) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
176 Ok(unique_configs_by_fleet(choices)?.remove(fleet))
177}
178
179fn unique_configs_by_fleet(
180 choices: &[PathBuf],
181) -> Result<BTreeMap<String, PathBuf>, ConfigDiscoveryError> {
182 let mut by_fleet = BTreeMap::<String, Vec<&PathBuf>>::new();
183 for path in choices {
184 if let Ok(fleet) = configured_fleet_name(path) {
185 by_fleet.entry(fleet).or_default().push(path);
186 }
187 }
188
189 for (fleet, paths) in &by_fleet {
190 if paths.len() > 1 {
191 let configs = paths
192 .iter()
193 .map(|path| path.display().to_string())
194 .collect::<Vec<_>>()
195 .join(", ");
196 return Err(ConfigDiscoveryError::DuplicateFleet {
197 fleet: fleet.clone(),
198 configs,
199 });
200 }
201 }
202
203 Ok(by_fleet
204 .into_iter()
205 .filter_map(|(fleet, mut paths)| paths.pop().cloned().map(|path| (fleet, path)))
206 .collect())
207}
208
209fn collect_canic_config_choices(
211 root: &Path,
212 choices: &mut Vec<PathBuf>,
213) -> Result<(), ConfigDiscoveryError> {
214 if !root.is_dir() {
215 return Ok(());
216 }
217
218 let entries = fs::read_dir(root).map_err(|source| ConfigDiscoveryError::Directory {
219 path: root.to_path_buf(),
220 source,
221 })?;
222 for entry in entries {
223 let entry = entry.map_err(|source| ConfigDiscoveryError::Directory {
224 path: root.to_path_buf(),
225 source,
226 })?;
227 let path = entry.path();
228 let file_type = entry
229 .file_type()
230 .map_err(|source| ConfigDiscoveryError::Path {
231 path: path.clone(),
232 source,
233 })?;
234 if file_type.is_symlink() {
235 continue;
236 }
237 if file_type.is_dir() {
238 collect_canic_config_choices(&path, choices)?;
239 } else if file_type.is_file()
240 && path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
241 {
242 choices.push(path);
243 }
244 }
245
246 Ok(())
247}
248
249pub(super) fn config_selection_error(
251 workspace_root: &Path,
252 default: &Path,
253 choices: &[PathBuf],
254) -> String {
255 let mut lines = vec![format!(
256 "missing default Canic config at {}",
257 display_workspace_path(workspace_root, default)
258 )];
259
260 if choices.is_empty() {
261 lines.push("create fleets/<fleet>/canic.toml and run canic install <fleet>".to_string());
262 return lines.join("\n");
263 }
264
265 if choices.len() == 1 {
266 let fleet = fleet_name_from_config_path(&choices[0]).unwrap_or("<fleet>");
267 lines.push(String::new());
268 lines.extend(config_choice_table(workspace_root, choices));
269 lines.push(String::new());
270 lines.push(format!("run: canic install {fleet}"));
271 return lines.join("\n");
272 }
273
274 lines.push("choose a fleet explicitly:".to_string());
275 lines.push(String::new());
276 lines.extend(config_choice_table(workspace_root, choices));
277 lines.push(String::new());
278 lines.push("run: canic install <fleet>".to_string());
279 lines.join("\n")
280}
281
282fn fleet_name_from_config_path(path: &Path) -> Option<&str> {
283 path.parent()?.file_name()?.to_str()
284}
285
286fn prompt_install_config_choice(
288 workspace_root: &Path,
289 default: &Path,
290 choices: &[PathBuf],
291) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
292 if choices.is_empty() || !io::stdin().is_terminal() {
293 return Ok(None);
294 }
295
296 eprintln!(
297 "missing default Canic config at {}",
298 display_workspace_path(workspace_root, default)
299 );
300 eprintln!();
301 for line in config_choice_table(workspace_root, choices) {
302 eprintln!("{line}");
303 }
304 eprintln!();
305
306 loop {
307 eprint!("enter config number (ctrl-c to quit): ");
308 io::stderr().flush()?;
309
310 let mut answer = String::new();
311 if io::stdin().read_line(&mut answer)? == 0 {
312 return Ok(None);
313 }
314
315 let trimmed = answer.trim();
316 let Ok(index) = trimmed.parse::<usize>() else {
317 eprintln!("invalid selection: {trimmed}");
318 continue;
319 };
320 let Some(path) = choices.get(index.saturating_sub(1)) else {
321 eprintln!("selection out of range: {index}");
322 continue;
323 };
324
325 return Ok(Some(path.clone()));
326 }
327}
328
329fn config_choice_table(workspace_root: &Path, choices: &[PathBuf]) -> Vec<String> {
331 let rows = choices
332 .iter()
333 .enumerate()
334 .map(|(index, path)| config_choice_row(workspace_root, index + 1, path))
335 .map(|row| [row.option, row.config, row.canisters])
336 .collect::<Vec<_>>();
337 render_table(
338 &["#", "CONFIG", "CANISTERS"],
339 &rows,
340 &[ColumnAlign::Right, ColumnAlign::Left, ColumnAlign::Left],
341 )
342 .lines()
343 .map(str::to_string)
344 .collect()
345}
346
347fn config_choice_row(workspace_root: &Path, option: usize, path: &Path) -> ConfigChoiceRow {
349 let config = display_workspace_path(workspace_root, path);
350 match configured_deployable_roles(path) {
351 Ok(roles) => ConfigChoiceRow {
352 option: option.to_string(),
353 config,
354 canisters: format_canister_summary(&roles),
355 },
356 Err(_) => ConfigChoiceRow {
357 option: option.to_string(),
358 config,
359 canisters: "invalid config".to_string(),
360 },
361 }
362}
363
364fn format_canister_summary(roles: &[String]) -> String {
366 if roles.is_empty() {
367 return "0".to_string();
368 }
369
370 let preview = roles
371 .iter()
372 .take(CONFIG_CHOICE_ROLE_PREVIEW_LIMIT)
373 .map(String::as_str)
374 .collect::<Vec<_>>()
375 .join(", ");
376 let suffix = if roles.len() > CONFIG_CHOICE_ROLE_PREVIEW_LIMIT {
377 ", ..."
378 } else {
379 ""
380 };
381
382 format!("{} ({preview}{suffix})", roles.len())
383}
384
385fn display_workspace_path(workspace_root: &Path, path: &Path) -> String {
387 path.strip_prefix(workspace_root)
388 .unwrap_or(path)
389 .display()
390 .to_string()
391}