use crate::release_set::{configured_fleet_name, configured_fleet_roles};
use crate::table::{ColumnAlign, render_table};
use crate::workspace_discovery::normalize_workspace_path;
use std::{
collections::BTreeMap,
env, fs,
io::{self, IsTerminal, Write},
path::{Path, PathBuf},
};
struct ConfigChoiceRow {
option: String,
config: String,
canisters: String,
}
const CONFIG_CHOICE_ROLE_PREVIEW_LIMIT: usize = 6;
const FLEETS_ROOT: &str = "fleets";
const ICP_CONFIG_FILE: &str = "icp.yaml";
const ROOT_CONFIG_RELATIVE: &str = "canic.toml";
pub fn current_canic_project_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?.canonicalize()?;
Ok(discover_canic_project_root_from(¤t_dir)?.unwrap_or(current_dir))
}
pub fn discover_canic_project_root_from(
start: &Path,
) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
let mut nearest_fleets_root = None;
for candidate in start.ancestors() {
if !discover_project_canic_config_choices(candidate)?.is_empty() {
let root = candidate.canonicalize()?;
if candidate.join(ICP_CONFIG_FILE).is_file() {
return Ok(Some(root));
}
if nearest_fleets_root.is_none() {
nearest_fleets_root = Some(root);
}
}
}
Ok(nearest_fleets_root)
}
pub(super) fn resolve_install_config_path(
workspace_root: &Path,
explicit_config_path: Option<&str>,
interactive: bool,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Some(path) = explicit_config_path {
return Ok(normalize_workspace_path(
workspace_root,
PathBuf::from(path),
));
}
if let Some(path) = env::var_os("CANIC_CONFIG_PATH") {
return Ok(normalize_workspace_path(
workspace_root,
PathBuf::from(path),
));
}
let default = workspace_root.join(FLEETS_ROOT).join(ROOT_CONFIG_RELATIVE);
if default.is_file() {
return Ok(default);
}
let choices = discover_workspace_canic_config_choices(workspace_root)?;
if interactive
&& let Some(path) = prompt_install_config_choice(workspace_root, &default, &choices)?
{
return Ok(path);
}
Err(config_selection_error(workspace_root, &default, &choices).into())
}
pub(super) fn discover_workspace_canic_config_choices(
workspace_root: &Path,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
discover_project_canic_config_choices(workspace_root)
}
pub fn discover_project_canic_config_choices(
project_root: &Path,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let mut choices = Vec::new();
for root in project_fleet_roots(project_root) {
collect_canic_config_choices(&root, &mut choices)?;
}
choices.sort();
choices.dedup();
reject_duplicate_fleet_names(&choices)?;
Ok(choices)
}
pub fn discover_canic_config_choices(
root: &Path,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let mut choices = Vec::new();
collect_canic_config_choices(root, &mut choices)?;
choices.sort();
reject_duplicate_fleet_names(&choices)?;
Ok(choices)
}
#[must_use]
pub fn project_fleet_roots(project_root: &Path) -> Vec<PathBuf> {
vec![project_root.join(FLEETS_ROOT)]
}
fn reject_duplicate_fleet_names(choices: &[PathBuf]) -> Result<(), Box<dyn std::error::Error>> {
let mut by_fleet = BTreeMap::<String, Vec<&PathBuf>>::new();
for path in choices {
if let Ok(fleet) = configured_fleet_name(path) {
by_fleet.entry(fleet).or_default().push(path);
}
}
for (fleet, paths) in by_fleet {
if paths.len() > 1 {
let configs = paths
.into_iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(format!("multiple configs declare fleet {fleet}: {configs}").into());
}
}
Ok(())
}
fn collect_canic_config_choices(
root: &Path,
choices: &mut Vec<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if !root.is_dir() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_canic_config_choices(&path, choices)?;
} else if file_type.is_file()
&& path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
&& is_install_project_config(&path)
{
choices.push(path);
}
}
Ok(())
}
const fn is_install_project_config(_path: &Path) -> bool {
true
}
pub(super) fn config_selection_error(
workspace_root: &Path,
default: &Path,
choices: &[PathBuf],
) -> String {
let mut lines = vec![format!(
"missing default Canic config at {}",
display_workspace_path(workspace_root, default)
)];
if choices.is_empty() {
lines.push("create fleets/<fleet>/canic.toml and run canic install <fleet>".to_string());
return lines.join("\n");
}
if choices.len() == 1 {
let fleet = fleet_name_from_config_path(&choices[0]).unwrap_or("<fleet>");
lines.push(String::new());
lines.extend(config_choice_table(workspace_root, choices));
lines.push(String::new());
lines.push(format!("run: canic install {fleet}"));
return lines.join("\n");
}
lines.push("choose a fleet explicitly:".to_string());
lines.push(String::new());
lines.extend(config_choice_table(workspace_root, choices));
lines.push(String::new());
lines.push("run: canic install <fleet>".to_string());
lines.join("\n")
}
fn fleet_name_from_config_path(path: &Path) -> Option<&str> {
path.parent()?.file_name()?.to_str()
}
fn prompt_install_config_choice(
workspace_root: &Path,
default: &Path,
choices: &[PathBuf],
) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
if choices.is_empty() || !io::stdin().is_terminal() {
return Ok(None);
}
eprintln!(
"missing default Canic config at {}",
display_workspace_path(workspace_root, default)
);
eprintln!();
for line in config_choice_table(workspace_root, choices) {
eprintln!("{line}");
}
eprintln!();
loop {
eprint!("enter config number (ctrl-c to quit): ");
io::stderr().flush()?;
let mut answer = String::new();
if io::stdin().read_line(&mut answer)? == 0 {
return Ok(None);
}
let trimmed = answer.trim();
let Ok(index) = trimmed.parse::<usize>() else {
eprintln!("invalid selection: {trimmed}");
continue;
};
let Some(path) = choices.get(index.saturating_sub(1)) else {
eprintln!("selection out of range: {index}");
continue;
};
return Ok(Some(path.clone()));
}
}
fn config_choice_table(workspace_root: &Path, choices: &[PathBuf]) -> Vec<String> {
let rows = choices
.iter()
.enumerate()
.map(|(index, path)| config_choice_row(workspace_root, index + 1, path))
.map(|row| [row.option, row.config, row.canisters])
.collect::<Vec<_>>();
render_table(
&["#", "CONFIG", "CANISTERS"],
&rows,
&[ColumnAlign::Right, ColumnAlign::Left, ColumnAlign::Left],
)
.lines()
.map(str::to_string)
.collect()
}
fn config_choice_row(workspace_root: &Path, option: usize, path: &Path) -> ConfigChoiceRow {
let config = display_workspace_path(workspace_root, path);
match configured_fleet_roles(path) {
Ok(roles) => ConfigChoiceRow {
option: option.to_string(),
config,
canisters: format_canister_summary(&roles),
},
Err(_) => ConfigChoiceRow {
option: option.to_string(),
config,
canisters: "invalid config".to_string(),
},
}
}
fn format_canister_summary(roles: &[String]) -> String {
if roles.is_empty() {
return "0".to_string();
}
let preview = roles
.iter()
.take(CONFIG_CHOICE_ROLE_PREVIEW_LIMIT)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
let suffix = if roles.len() > CONFIG_CHOICE_ROLE_PREVIEW_LIMIT {
", ..."
} else {
""
};
format!("{} ({preview}{suffix})", roles.len())
}
fn display_workspace_path(workspace_root: &Path, path: &Path) -> String {
path.strip_prefix(workspace_root)
.unwrap_or(path)
.display()
.to_string()
}