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