Skip to main content

canic_host/install_root/
config_selection.rs

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
14///
15/// ConfigChoiceRow
16///
17
18struct 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///
30/// ConfigDiscoveryError
31///
32
33#[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
66// Resolve the operator-facing Canic project root from the current directory.
67pub 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(&current_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
103// Resolve install config selection without silently choosing among demo/test configs.
104pub(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
131// Discover installable Canic config choices from the fleet root.
132pub(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
138// Discover candidate `canic.toml` files under conventional project fleet roots.
139pub 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
152// Discover candidate `canic.toml` files under one fleet config root.
153pub 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
171/// Select one config from a canonically admitted discovery result by fleet name.
172pub 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
209// Recursively collect candidate config paths.
210fn 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
249// Format an actionable config-selection error with whitespace-aligned choices.
250pub(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
286// Prompt interactively for one discovered config when running in a terminal.
287fn 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
329// Render config choices with enough metadata to choose the intended topology.
330fn 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
347// Summarize the root-subnet deployable roles for one install config choice.
348fn 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
364// Format the root-subnet canister count with a bounded role preview.
365fn 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
385// Render a workspace-relative path where possible for concise diagnostics.
386fn 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}