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#[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#[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#[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
132pub(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
138pub 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
144pub 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
180pub 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
188pub 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
239pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
241 let search_root = current_project_search_root()?;
242 let choices = discover_project_canic_config_choices(&search_root)
243 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
244 if !choices.is_empty() {
245 return Ok(search_root);
246 }
247
248 current_icp_root().or_else(|_| {
249 icp_root()
250 .map_err(|err| IcpConfigError::Config(err.to_string()))
251 .and_then(|path| path.canonicalize().map_err(IcpConfigError::from))
252 })
253}
254
255fn current_project_search_root() -> Result<PathBuf, IcpConfigError> {
256 let root = current_canic_project_root()
257 .map_err(|err| IcpConfigError::Config(err.to_string()))?
258 .canonicalize()?;
259 if !discover_project_canic_config_choices(&root)
260 .map_err(|err| IcpConfigError::Config(err.to_string()))?
261 .is_empty()
262 {
263 return Ok(root);
264 }
265
266 if let Ok(root) = icp_root() {
267 return Ok(root);
268 }
269 Ok(std::env::current_dir()?.canonicalize()?)
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
277struct CanicIcpSpec {
278 canisters: Vec<String>,
279 environments: BTreeMap<String, Vec<String>>,
280}
281
282fn discover_project_spec(
283 root: &Path,
284 fleet_filter: Option<&str>,
285) -> Result<CanicIcpSpec, IcpConfigError> {
286 let choices = discover_project_canic_config_choices(root)
287 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
288 if choices.is_empty() {
289 return Err(IcpConfigError::Config(format!(
290 "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
291 display_project_fleet_roots(root)
292 )));
293 }
294
295 let mut canisters = Vec::<String>::new();
296 let mut seen_canisters = BTreeSet::<String>::new();
297 let mut environments = BTreeMap::<String, Vec<String>>::new();
298 let mut matched_filter = fleet_filter.is_none();
299
300 for config_path in choices {
301 let fleet = configured_fleet_name(&config_path)
302 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
303 if let Some(filter) = fleet_filter {
304 if filter != fleet {
305 continue;
306 }
307 matched_filter = true;
308 }
309
310 let roles = configured_deployable_roles(&config_path)
311 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
312 for role in &roles {
313 if seen_canisters.insert(role.clone()) {
314 canisters.push(role.clone());
315 }
316 }
317 environments.insert(fleet, roles);
318 }
319
320 if let Some(fleet) = fleet_filter
321 && !matched_filter
322 {
323 return Err(IcpConfigError::Config(format!(
324 "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
325 display_project_fleet_roots(root)
326 )));
327 }
328
329 Ok(CanicIcpSpec {
330 canisters,
331 environments,
332 })
333}
334
335fn display_project_fleet_roots(root: &Path) -> String {
336 project_fleet_roots(root)
337 .into_iter()
338 .map(|path| path.display().to_string())
339 .collect::<Vec<_>>()
340 .join(" or ")
341}
342
343fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
344 let start = lines
345 .iter()
346 .position(|line| line_indent(line) == 0 && line.trim() == header)?;
347 let end = lines
348 .iter()
349 .enumerate()
350 .skip(start + 1)
351 .find(|(_, line)| {
352 !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
353 })
354 .map_or(lines.len(), |(index, _)| index);
355 Some((start, end))
356}
357
358fn resolve_icp_build_environment_from_yaml(
359 source: &str,
360 environment: &str,
361) -> Result<IcpBuildEnvironment, String> {
362 let lines = source.lines().collect::<Vec<_>>();
363 let (_, environment_start, environment_end) =
364 named_item_block(&lines, "environments:", environment)?.ok_or_else(|| {
365 format!(
366 "ICP environment '{environment}' is not declared; add it under environments or use the implicit local/ic environment"
367 )
368 })?;
369 let network = item_scalar_field(&lines, environment_start, environment_end, "network")?
370 .ok_or_else(|| format!("ICP environment '{environment}' has no network"))?;
371
372 match network.as_str() {
373 "ic" => Ok(IcpBuildEnvironment::Ic),
374 "local" => Ok(IcpBuildEnvironment::Local),
375 _ => {
376 let (_, network_start, network_end) = named_item_block(&lines, "networks:", &network)?
377 .ok_or_else(|| {
378 format!(
379 "ICP environment '{environment}' references undeclared network '{network}'"
380 )
381 })?;
382 let mode = item_scalar_field(&lines, network_start, network_end, "mode")?
383 .ok_or_else(|| format!("ICP network '{network}' has no mode"))?;
384 match mode.as_str() {
385 "connected" | "managed" => Ok(IcpBuildEnvironment::Local),
388 _ => Err(format!(
389 "ICP network '{network}' has unsupported mode '{mode}'"
390 )),
391 }
392 }
393 }
394}
395
396fn named_item_block(
397 lines: &[&str],
398 section: &str,
399 name: &str,
400) -> Result<Option<(String, usize, usize)>, String> {
401 let Some((section_start, section_end)) = top_level_section(lines, section) else {
402 return Ok(None);
403 };
404 let starts = lines[section_start + 1..section_end]
405 .iter()
406 .enumerate()
407 .filter_map(|(offset, line)| {
408 if line_indent(line) != 2 {
409 return None;
410 }
411 line.trim()
412 .strip_prefix("- name:")
413 .map(trim_yaml_scalar)
414 .filter(|item_name| !item_name.is_empty())
415 .map(|item_name| (item_name.to_string(), section_start + 1 + offset))
416 })
417 .collect::<Vec<_>>();
418 let matches = starts
419 .iter()
420 .enumerate()
421 .filter(|(_, (item_name, _))| item_name == name)
422 .collect::<Vec<_>>();
423 let [(match_index, (item_name, start))] = matches.as_slice() else {
424 return if matches.is_empty() {
425 Ok(None)
426 } else {
427 Err(format!("duplicate '{name}' entries under {section}"))
428 };
429 };
430 let end = starts
431 .get(match_index + 1)
432 .map_or(section_end, |(_, next_start)| *next_start);
433 Ok(Some((item_name.clone(), *start, end)))
434}
435
436fn item_scalar_field(
437 lines: &[&str],
438 start: usize,
439 end: usize,
440 field: &str,
441) -> Result<Option<String>, String> {
442 let prefix = format!("{field}:");
443 let values = lines[start + 1..end]
444 .iter()
445 .filter_map(|line| {
446 if line_indent(line) != 4 {
447 return None;
448 }
449 line.trim()
450 .strip_prefix(&prefix)
451 .map(trim_yaml_scalar)
452 .filter(|value| !value.is_empty())
453 .map(str::to_string)
454 })
455 .collect::<Vec<_>>();
456 match values.as_slice() {
457 [] => Ok(None),
458 [value] => Ok(Some(value.clone())),
459 _ => Err(format!(
460 "duplicate '{field}' fields in '{}'",
461 section_name(lines, start)
462 )),
463 }
464}
465
466fn section_name<'a>(lines: &'a [&'a str], start: usize) -> &'a str {
467 lines[start]
468 .trim()
469 .strip_prefix("- name:")
470 .map_or("item", trim_yaml_scalar)
471}
472
473fn local_gateway_port_from_yaml(source: &str) -> u16 {
474 let lines = source.lines().collect::<Vec<_>>();
475 let Some((start, end)) = local_network_block(&lines) else {
476 return DEFAULT_LOCAL_GATEWAY_PORT;
477 };
478
479 lines[start..end]
480 .iter()
481 .find_map(|line| {
482 line.trim()
483 .strip_prefix("port:")
484 .and_then(|value| value.trim().parse::<u16>().ok())
485 })
486 .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
487}
488
489fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
490 let (section_start, section_end) = top_level_section(lines, "networks:")?;
491 let start = lines[section_start + 1..section_end]
492 .iter()
493 .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
494 + section_start
495 + 1;
496 let end = lines[start + 1..section_end]
497 .iter()
498 .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
499 .map_or(section_end, |offset| start + 1 + offset);
500 Some((start, end))
501}
502
503fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
504 let lines = source.lines().collect::<Vec<_>>();
505 let Some((start, end)) = top_level_section(&lines, header) else {
506 return BTreeSet::new();
507 };
508
509 lines[start + 1..end]
510 .iter()
511 .filter_map(|line| {
512 if line_indent(line) != 2 {
513 return None;
514 }
515 line.trim()
516 .strip_prefix("- name:")
517 .map(trim_yaml_scalar)
518 .filter(|name| !name.is_empty())
519 .map(str::to_string)
520 })
521 .collect()
522}
523
524fn trim_yaml_scalar(value: &str) -> &str {
525 value.trim().trim_matches('"').trim_matches('\'')
526}
527
528fn line_indent(line: &str) -> usize {
529 line.chars().take_while(|c| *c == ' ').count()
530}
531
532#[cfg(test)]
533mod tests;