Skip to main content

canic_host/install_root/
config_selection.rs

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
12///
13/// ConfigChoiceRow
14///
15
16struct 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///
28/// ConfigDiscoveryError
29///
30
31#[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
61// Resolve the operator-facing Canic project root from the current directory.
62pub 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(&current_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
98// Resolve install config selection without silently choosing among demo/test configs.
99pub(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
126// Discover installable Canic config choices from the fleet root.
127pub(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
133// Discover candidate `canic.toml` files under conventional project fleet roots.
134pub 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
147// Discover candidate `canic.toml` files under one fleet config root.
148pub 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
166/// Select one config from a canonically admitted discovery result by fleet name.
167pub 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
204// Recursively collect candidate config paths.
205fn 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
244// Format an actionable config-selection error with whitespace-aligned choices.
245pub(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
281// Prompt interactively for one discovered config when running in a terminal.
282fn 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
324// Render config choices with enough metadata to choose the intended topology.
325fn 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
342// Summarize the root-subnet deployable roles for one install config choice.
343fn 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
359// Format the root-subnet canister count with a bounded role preview.
360fn 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
380// Render a workspace-relative path where possible for concise diagnostics.
381fn 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}