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 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#[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 "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;