Skip to main content

canic_host/icp_config/
mod.rs

1use crate::{
2    install_root::{
3        ConfigDiscoveryError, current_canic_project_root, discover_project_canic_config_choices,
4        project_fleet_roots,
5    },
6    release_set::{FleetConfigError, FleetConfigSnapshot, WorkspaceDiscoveryError, icp_root},
7    workspace_discovery::discover_icp_root_from,
8};
9use canic_core::ids::BuildNetwork;
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fs,
13    path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17const ICP_CONFIG_FILE: &str = "icp.yaml";
18pub const DEFAULT_LOCAL_GATEWAY_PORT: u16 = 8000;
19
20///
21/// IcpConfigError
22///
23
24#[derive(Debug, ThisError)]
25pub enum IcpConfigError {
26    #[error("could not find icp.yaml from {}", start.display())]
27    NoIcpRoot { start: PathBuf },
28
29    #[error("{0}")]
30    Config(String),
31
32    #[error(transparent)]
33    ConfigDiscovery(#[from] ConfigDiscoveryError),
34
35    #[error(transparent)]
36    FleetConfig(#[from] FleetConfigError),
37
38    #[error(transparent)]
39    WorkspaceDiscovery(#[from] WorkspaceDiscoveryError),
40
41    #[error(transparent)]
42    Io(#[from] std::io::Error),
43}
44
45///
46/// IcpProjectConfigReport
47///
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct IcpProjectConfigReport {
51    pub path: PathBuf,
52    pub icp_root: PathBuf,
53    pub icp_yaml_present: bool,
54    pub canisters: Vec<String>,
55    pub environments: Vec<String>,
56    pub missing_canisters: Vec<String>,
57    pub missing_environments: Vec<String>,
58    pub local_network_present: bool,
59}
60
61impl IcpProjectConfigReport {
62    #[must_use]
63    pub const fn is_ready(&self) -> bool {
64        self.icp_yaml_present
65            && self.local_network_present
66            && self.missing_canisters.is_empty()
67            && self.missing_environments.is_empty()
68    }
69
70    #[must_use]
71    pub fn issues(&self) -> Vec<String> {
72        let mut issues = Vec::new();
73        if !self.icp_yaml_present {
74            issues.push(format!("missing {}", self.path.display()));
75        }
76        if !self.local_network_present {
77            issues.push("missing local network entry".to_string());
78        }
79        if !self.missing_canisters.is_empty() {
80            issues.push(format!(
81                "missing canisters: {}",
82                self.missing_canisters.join(", ")
83            ));
84        }
85        if !self.missing_environments.is_empty() {
86            issues.push(format!(
87                "missing environments: {}",
88                self.missing_environments.join(", ")
89            ));
90        }
91        issues
92    }
93}
94
95/// Return the configured local ICP gateway port, falling back to ICP's default.
96pub(crate) fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
97    let root = current_icp_root()?;
98    configured_local_gateway_port_from_root(&root)
99}
100
101/// Return the configured local ICP gateway port for one ICP project root.
102pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
103    let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
104    Ok(local_gateway_port_from_yaml(&source))
105}
106
107/// Resolve a selected ICP environment to the build-time network class used by Canic.
108///
109/// The implicit `local` and `ic` environments resolve without project config.
110/// Other names must exist under `environments` in `icp.yaml`; their declared
111/// ICP network decides whether Cargo builds a local/test or IC mainnet artifact.
112pub fn resolve_icp_build_network_from_root(
113    root: &Path,
114    environment: &str,
115) -> Result<BuildNetwork, IcpConfigError> {
116    let environment = environment.trim();
117    if environment.is_empty() {
118        return Err(IcpConfigError::Config(
119            "ICP environment name must not be empty".to_string(),
120        ));
121    }
122    match environment {
123        "local" => return Ok(BuildNetwork::Local),
124        "ic" => return Ok(BuildNetwork::Ic),
125        _ => {}
126    }
127
128    let path = root.join(ICP_CONFIG_FILE);
129    let source = fs::read_to_string(&path).map_err(|err| {
130        if err.kind() == std::io::ErrorKind::NotFound {
131            IcpConfigError::Config(format!(
132                "ICP environment '{environment}' cannot be resolved because {} is missing",
133                path.display()
134            ))
135        } else {
136            IcpConfigError::Io(err)
137        }
138    })?;
139    resolve_icp_build_network_from_yaml(&source, environment)
140        .map_err(|message| IcpConfigError::Config(format!("{}: {message}", path.display())))
141}
142
143/// Inspect whether `icp.yaml` contains the entries implied by Canic fleet configs.
144pub fn inspect_canic_icp_yaml(
145    fleet_filter: Option<&str>,
146) -> Result<IcpProjectConfigReport, IcpConfigError> {
147    let root = resolve_current_canic_icp_root()?;
148    inspect_canic_icp_yaml_from_root(&root, fleet_filter)
149}
150
151/// Inspect one ICP project root without mutating its `icp.yaml`.
152pub fn inspect_canic_icp_yaml_from_root(
153    root: &Path,
154    fleet_filter: Option<&str>,
155) -> Result<IcpProjectConfigReport, IcpConfigError> {
156    let path = root.join(ICP_CONFIG_FILE);
157    let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
158    let spec = discover_project_spec(root, fleet_filter)?;
159    let configured_canisters = top_level_named_items(&source, "canisters:");
160    let configured_environments = top_level_named_items(&source, "environments:");
161    let lines = source.lines().collect::<Vec<_>>();
162    let local_network_present = local_network_block(&lines).is_some();
163
164    let missing_canisters = spec
165        .canisters
166        .iter()
167        .filter(|name| !configured_canisters.contains(*name))
168        .cloned()
169        .collect::<Vec<_>>();
170    let missing_environments = spec
171        .environments
172        .keys()
173        .filter(|name| !configured_environments.contains(*name))
174        .cloned()
175        .collect::<Vec<_>>();
176
177    Ok(IcpProjectConfigReport {
178        path,
179        icp_root: root.to_path_buf(),
180        icp_yaml_present,
181        canisters: spec.canisters,
182        environments: spec.environments.into_keys().collect(),
183        missing_canisters,
184        missing_environments,
185        local_network_present,
186    })
187}
188
189fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
190    match fs::read_to_string(path) {
191        Ok(source) => Ok((source, true)),
192        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
193        Err(err) => Err(err.into()),
194    }
195}
196
197fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
198    let start = std::env::current_dir().map_err(WorkspaceDiscoveryError::CurrentDirectory)?;
199    discover_icp_root_from(&start)?.ok_or(IcpConfigError::NoIcpRoot { start })
200}
201
202/// Resolve the ICP project root implied by the current Canic fleet layout.
203pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
204    let root = current_canic_project_root()?.canonicalize()?;
205    if !discover_project_canic_config_choices(&root)?.is_empty() {
206        return Ok(root);
207    }
208
209    Ok(icp_root()?)
210}
211
212///
213/// CanicIcpSpec
214///
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217struct CanicIcpSpec {
218    canisters: Vec<String>,
219    environments: BTreeMap<String, Vec<String>>,
220}
221
222fn discover_project_spec(
223    root: &Path,
224    fleet_filter: Option<&str>,
225) -> Result<CanicIcpSpec, IcpConfigError> {
226    let choices = discover_project_canic_config_choices(root)?;
227    if choices.is_empty() {
228        return Err(IcpConfigError::Config(format!(
229            "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
230            display_project_fleet_roots(root)
231        )));
232    }
233
234    let mut canisters = Vec::<String>::new();
235    let mut seen_canisters = BTreeSet::<String>::new();
236    let mut environments = BTreeMap::<String, Vec<String>>::new();
237    let mut matched_filter = fleet_filter.is_none();
238
239    for config_path in choices {
240        let config = FleetConfigSnapshot::load(&config_path)?;
241        let fleet = config.fleet_name().to_string();
242        if let Some(filter) = fleet_filter {
243            if filter != fleet {
244                continue;
245            }
246            matched_filter = true;
247        }
248
249        let roles = config.deployable_roles();
250        for role in &roles {
251            if seen_canisters.insert(role.clone()) {
252                canisters.push(role.clone());
253            }
254        }
255        environments.insert(fleet, roles);
256    }
257
258    if let Some(fleet) = fleet_filter
259        && !matched_filter
260    {
261        return Err(IcpConfigError::Config(format!(
262            "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
263            display_project_fleet_roots(root)
264        )));
265    }
266
267    Ok(CanicIcpSpec {
268        canisters,
269        environments,
270    })
271}
272
273fn display_project_fleet_roots(root: &Path) -> String {
274    project_fleet_roots(root)
275        .into_iter()
276        .map(|path| path.display().to_string())
277        .collect::<Vec<_>>()
278        .join(" or ")
279}
280
281fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
282    let start = lines
283        .iter()
284        .position(|line| line_indent(line) == 0 && line.trim() == header)?;
285    let end = lines
286        .iter()
287        .enumerate()
288        .skip(start + 1)
289        .find(|(_, line)| {
290            !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
291        })
292        .map_or(lines.len(), |(index, _)| index);
293    Some((start, end))
294}
295
296fn resolve_icp_build_network_from_yaml(
297    source: &str,
298    environment: &str,
299) -> Result<BuildNetwork, String> {
300    let lines = source.lines().collect::<Vec<_>>();
301    let (_, environment_start, environment_end) =
302        named_item_block(&lines, "environments:", environment)?.ok_or_else(|| {
303            format!(
304                "ICP environment '{environment}' is not declared under environments; add it or use the implicit local/ic environment"
305            )
306        })?;
307    let configured_network =
308        item_scalar_field(&lines, environment_start, environment_end, "network")?.ok_or_else(
309            || format!("ICP environment '{environment}' has no configured backing network"),
310        )?;
311
312    match configured_network.as_str() {
313        "ic" => Ok(BuildNetwork::Ic),
314        "local" => Ok(BuildNetwork::Local),
315        _ => {
316            let (_, network_start, network_end) =
317                named_item_block(&lines, "networks:", &configured_network)?.ok_or_else(|| {
318                    format!(
319                        "ICP environment '{environment}' references undeclared backing network '{configured_network}'"
320                    )
321                })?;
322            let mode = item_scalar_field(&lines, network_start, network_end, "mode")?
323                .ok_or_else(|| format!("ICP backing network '{configured_network}' has no mode"))?;
324            match mode.as_str() {
325                // ICP CLI reserves the implicit `ic` network for mainnet.
326                // Declared managed and connected networks are non-mainnet build classes.
327                "connected" | "managed" => Ok(BuildNetwork::Local),
328                _ => Err(format!(
329                    "ICP backing network '{configured_network}' has unsupported mode '{mode}'"
330                )),
331            }
332        }
333    }
334}
335
336fn named_item_block(
337    lines: &[&str],
338    section: &str,
339    name: &str,
340) -> Result<Option<(String, usize, usize)>, String> {
341    let Some((section_start, section_end)) = top_level_section(lines, section) else {
342        return Ok(None);
343    };
344    let starts = lines[section_start + 1..section_end]
345        .iter()
346        .enumerate()
347        .filter_map(|(offset, line)| {
348            if line_indent(line) != 2 {
349                return None;
350            }
351            line.trim()
352                .strip_prefix("- name:")
353                .map(trim_yaml_scalar)
354                .filter(|item_name| !item_name.is_empty())
355                .map(|item_name| (item_name.to_string(), section_start + 1 + offset))
356        })
357        .collect::<Vec<_>>();
358    let matches = starts
359        .iter()
360        .enumerate()
361        .filter(|(_, (item_name, _))| item_name == name)
362        .collect::<Vec<_>>();
363    let [(match_index, (item_name, start))] = matches.as_slice() else {
364        return if matches.is_empty() {
365            Ok(None)
366        } else {
367            Err(format!("duplicate '{name}' entries under {section}"))
368        };
369    };
370    let end = starts
371        .get(match_index + 1)
372        .map_or(section_end, |(_, next_start)| *next_start);
373    Ok(Some((item_name.clone(), *start, end)))
374}
375
376fn item_scalar_field(
377    lines: &[&str],
378    start: usize,
379    end: usize,
380    field: &str,
381) -> Result<Option<String>, String> {
382    let prefix = format!("{field}:");
383    let values = lines[start + 1..end]
384        .iter()
385        .filter_map(|line| {
386            if line_indent(line) != 4 {
387                return None;
388            }
389            line.trim()
390                .strip_prefix(&prefix)
391                .map(trim_yaml_scalar)
392                .filter(|value| !value.is_empty())
393                .map(str::to_string)
394        })
395        .collect::<Vec<_>>();
396    match values.as_slice() {
397        [] => Ok(None),
398        [value] => Ok(Some(value.clone())),
399        _ => Err(format!(
400            "duplicate '{field}' fields in '{}'",
401            section_name(lines, start)
402        )),
403    }
404}
405
406fn section_name<'a>(lines: &'a [&'a str], start: usize) -> &'a str {
407    lines[start]
408        .trim()
409        .strip_prefix("- name:")
410        .map_or("item", trim_yaml_scalar)
411}
412
413fn local_gateway_port_from_yaml(source: &str) -> u16 {
414    let lines = source.lines().collect::<Vec<_>>();
415    let Some((start, end)) = local_network_block(&lines) else {
416        return DEFAULT_LOCAL_GATEWAY_PORT;
417    };
418
419    lines[start..end]
420        .iter()
421        .find_map(|line| {
422            line.trim()
423                .strip_prefix("port:")
424                .and_then(|value| value.trim().parse::<u16>().ok())
425        })
426        .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
427}
428
429fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
430    let (section_start, section_end) = top_level_section(lines, "networks:")?;
431    let start = lines[section_start + 1..section_end]
432        .iter()
433        .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
434        + section_start
435        + 1;
436    let end = lines[start + 1..section_end]
437        .iter()
438        .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
439        .map_or(section_end, |offset| start + 1 + offset);
440    Some((start, end))
441}
442
443fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
444    let lines = source.lines().collect::<Vec<_>>();
445    let Some((start, end)) = top_level_section(&lines, header) else {
446        return BTreeSet::new();
447    };
448
449    lines[start + 1..end]
450        .iter()
451        .filter_map(|line| {
452            if line_indent(line) != 2 {
453                return None;
454            }
455            line.trim()
456                .strip_prefix("- name:")
457                .map(trim_yaml_scalar)
458                .filter(|name| !name.is_empty())
459                .map(str::to_string)
460        })
461        .collect()
462}
463
464fn trim_yaml_scalar(value: &str) -> &str {
465    value.trim().trim_matches('"').trim_matches('\'')
466}
467
468fn line_indent(line: &str) -> usize {
469    line.chars().take_while(|c| *c == ' ').count()
470}
471
472#[cfg(test)]
473mod tests;