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