Skip to main content

canic_host/icp_config/
mod.rs

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