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(Debug)]
23pub enum IcpConfigError {
24 NoIcpRoot { start: PathBuf },
25 Config(String),
26 Io(std::io::Error),
27}
28
29impl fmt::Display for IcpConfigError {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::NoIcpRoot { start } => {
33 write!(
34 formatter,
35 "could not find icp.yaml from {}",
36 start.display()
37 )
38 }
39 Self::Config(message) => write!(formatter, "{message}"),
40 Self::Io(err) => write!(formatter, "{err}"),
41 }
42 }
43}
44
45impl Error for IcpConfigError {
46 fn source(&self) -> Option<&(dyn Error + 'static)> {
47 match self {
48 Self::Io(err) => Some(err),
49 Self::Config(_) | Self::NoIcpRoot { .. } => None,
50 }
51 }
52}
53
54impl From<std::io::Error> for IcpConfigError {
55 fn from(err: std::io::Error) -> Self {
56 Self::Io(err)
57 }
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct IcpProjectConfigReport {
66 pub path: PathBuf,
67 pub icp_root: PathBuf,
68 pub icp_yaml_present: bool,
69 pub canisters: Vec<String>,
70 pub environments: Vec<String>,
71 pub missing_canisters: Vec<String>,
72 pub missing_environments: Vec<String>,
73 pub local_network_present: bool,
74}
75
76impl IcpProjectConfigReport {
77 #[must_use]
78 pub const fn is_ready(&self) -> bool {
79 self.icp_yaml_present
80 && self.local_network_present
81 && self.missing_canisters.is_empty()
82 && self.missing_environments.is_empty()
83 }
84
85 #[must_use]
86 pub fn issues(&self) -> Vec<String> {
87 let mut issues = Vec::new();
88 if !self.icp_yaml_present {
89 issues.push(format!("missing {}", self.path.display()));
90 }
91 if !self.local_network_present {
92 issues.push("missing local network entry".to_string());
93 }
94 if !self.missing_canisters.is_empty() {
95 issues.push(format!(
96 "missing canisters: {}",
97 self.missing_canisters.join(", ")
98 ));
99 }
100 if !self.missing_environments.is_empty() {
101 issues.push(format!(
102 "missing environments: {}",
103 self.missing_environments.join(", ")
104 ));
105 }
106 issues
107 }
108}
109
110pub(crate) fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
112 let root = current_icp_root()?;
113 configured_local_gateway_port_from_root(&root)
114}
115
116pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
118 let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
119 Ok(local_gateway_port_from_yaml(&source))
120}
121
122pub fn inspect_canic_icp_yaml(
124 fleet_filter: Option<&str>,
125) -> Result<IcpProjectConfigReport, IcpConfigError> {
126 let root = resolve_current_canic_icp_root()?;
127 inspect_canic_icp_yaml_from_root(&root, fleet_filter)
128}
129
130pub fn inspect_canic_icp_yaml_from_root(
132 root: &Path,
133 fleet_filter: Option<&str>,
134) -> Result<IcpProjectConfigReport, IcpConfigError> {
135 let path = root.join(ICP_CONFIG_FILE);
136 let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
137 let spec = discover_project_spec(root, fleet_filter)?;
138 let configured_canisters = top_level_named_items(&source, "canisters:");
139 let configured_environments = top_level_named_items(&source, "environments:");
140 let lines = source.lines().collect::<Vec<_>>();
141 let local_network_present = local_network_block(&lines).is_some();
142
143 let missing_canisters = spec
144 .canisters
145 .iter()
146 .filter(|name| !configured_canisters.contains(*name))
147 .cloned()
148 .collect::<Vec<_>>();
149 let missing_environments = spec
150 .environments
151 .keys()
152 .filter(|name| !configured_environments.contains(*name))
153 .cloned()
154 .collect::<Vec<_>>();
155
156 Ok(IcpProjectConfigReport {
157 path,
158 icp_root: root.to_path_buf(),
159 icp_yaml_present,
160 canisters: spec.canisters,
161 environments: spec.environments.into_keys().collect(),
162 missing_canisters,
163 missing_environments,
164 local_network_present,
165 })
166}
167
168fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
169 match fs::read_to_string(path) {
170 Ok(source) => Ok((source, true)),
171 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
172 Err(err) => Err(err.into()),
173 }
174}
175
176fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
177 let start = std::env::current_dir()?;
178 discover_icp_root_from(&start).ok_or(IcpConfigError::NoIcpRoot { start })
179}
180
181pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
183 if let Ok(path) = std::env::var("CANIC_ICP_ROOT") {
184 return PathBuf::from(path)
185 .canonicalize()
186 .map_err(IcpConfigError::from);
187 }
188
189 let search_root = current_project_search_root()?;
190 let choices = discover_project_canic_config_choices(&search_root)
191 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
192 if !choices.is_empty() {
193 return Ok(search_root);
194 }
195
196 current_icp_root().or_else(|_| {
197 icp_root()
198 .map_err(|err| IcpConfigError::Config(err.to_string()))
199 .and_then(|path| path.canonicalize().map_err(IcpConfigError::from))
200 })
201}
202
203fn current_project_search_root() -> Result<PathBuf, IcpConfigError> {
204 let root = current_canic_project_root()
205 .map_err(|err| IcpConfigError::Config(err.to_string()))?
206 .canonicalize()?;
207 if !discover_project_canic_config_choices(&root)
208 .map_err(|err| IcpConfigError::Config(err.to_string()))?
209 .is_empty()
210 {
211 return Ok(root);
212 }
213
214 if let Ok(root) = icp_root() {
215 return Ok(root);
216 }
217 Ok(std::env::current_dir()?.canonicalize()?)
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
225struct CanicIcpSpec {
226 canisters: Vec<String>,
227 environments: BTreeMap<String, Vec<String>>,
228}
229
230fn discover_project_spec(
231 root: &Path,
232 fleet_filter: Option<&str>,
233) -> Result<CanicIcpSpec, IcpConfigError> {
234 let choices = discover_project_canic_config_choices(root)
235 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
236 if choices.is_empty() {
237 return Err(IcpConfigError::Config(format!(
238 "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
239 display_project_fleet_roots(root)
240 )));
241 }
242
243 let mut canisters = Vec::<String>::new();
244 let mut seen_canisters = BTreeSet::<String>::new();
245 let mut environments = BTreeMap::<String, Vec<String>>::new();
246 let mut matched_filter = fleet_filter.is_none();
247
248 for config_path in choices {
249 let fleet = configured_fleet_name(&config_path)
250 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
251 if let Some(filter) = fleet_filter {
252 if filter != fleet {
253 continue;
254 }
255 matched_filter = true;
256 }
257
258 let roles = configured_deployable_roles(&config_path)
259 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
260 for role in &roles {
261 if seen_canisters.insert(role.clone()) {
262 canisters.push(role.clone());
263 }
264 }
265 environments.insert(fleet, roles);
266 }
267
268 if let Some(fleet) = fleet_filter
269 && !matched_filter
270 {
271 return Err(IcpConfigError::Config(format!(
272 "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
273 display_project_fleet_roots(root)
274 )));
275 }
276
277 Ok(CanicIcpSpec {
278 canisters,
279 environments,
280 })
281}
282
283fn display_project_fleet_roots(root: &Path) -> String {
284 project_fleet_roots(root)
285 .into_iter()
286 .map(|path| path.display().to_string())
287 .collect::<Vec<_>>()
288 .join(" or ")
289}
290
291fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
292 let start = lines
293 .iter()
294 .position(|line| line_indent(line) == 0 && line.trim() == header)?;
295 let end = lines
296 .iter()
297 .enumerate()
298 .skip(start + 1)
299 .find(|(_, line)| {
300 !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
301 })
302 .map_or(lines.len(), |(index, _)| index);
303 Some((start, end))
304}
305
306fn local_gateway_port_from_yaml(source: &str) -> u16 {
307 let lines = source.lines().collect::<Vec<_>>();
308 let Some((start, end)) = local_network_block(&lines) else {
309 return DEFAULT_LOCAL_GATEWAY_PORT;
310 };
311
312 lines[start..end]
313 .iter()
314 .find_map(|line| {
315 line.trim()
316 .strip_prefix("port:")
317 .and_then(|value| value.trim().parse::<u16>().ok())
318 })
319 .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
320}
321
322fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
323 let (section_start, section_end) = top_level_section(lines, "networks:")?;
324 let start = lines[section_start + 1..section_end]
325 .iter()
326 .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
327 + section_start
328 + 1;
329 let end = lines[start + 1..section_end]
330 .iter()
331 .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
332 .map_or(section_end, |offset| start + 1 + offset);
333 Some((start, end))
334}
335
336fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
337 let lines = source.lines().collect::<Vec<_>>();
338 let Some((start, end)) = top_level_section(&lines, header) else {
339 return BTreeSet::new();
340 };
341
342 lines[start + 1..end]
343 .iter()
344 .filter_map(|line| {
345 if line_indent(line) != 2 {
346 return None;
347 }
348 line.trim()
349 .strip_prefix("- name:")
350 .map(trim_yaml_scalar)
351 .filter(|name| !name.is_empty())
352 .map(str::to_string)
353 })
354 .collect()
355}
356
357fn trim_yaml_scalar(value: &str) -> &str {
358 value.trim().trim_matches('"').trim_matches('\'')
359}
360
361fn line_indent(line: &str) -> usize {
362 line.chars().take_while(|c| *c == ' ').count()
363}
364
365#[cfg(test)]
366mod tests;