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