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#[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#[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
98pub(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
104pub 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
110pub 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
146pub 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
154pub 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
205pub 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#[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 "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;