1pub mod cargo_manifest;
7pub mod env_files;
8pub mod js_package_manager;
9pub mod node_toolchain;
10pub mod pnpm_package_json;
11pub mod process_monitor_json;
12pub mod project_paths;
13pub mod tray_runtime;
14pub mod version;
15pub mod xbp_ignore;
16
17pub use cargo_manifest::{
18 resolve_cargo_package_version, resolve_cargo_package_version_required,
19 write_cargo_package_version,
20};
21pub use env_files::{
22 canonicalize_env_value, env_reference_name, escape_env_value_double_quoted,
23 first_dotenv_value_upwards, first_lookup_value, format_env_assignment, format_env_file_content,
24 format_env_value_double_quoted, load_env_lookup, normalize_env_key, normalize_env_value,
25 parse_env_content, parse_env_file, preferred_local_secret_env_path, resolve_env_placeholders,
26 secret_like_key, strip_utf8_bom, to_env_references, upsert_env_key,
27 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS, DOTENV_FILE_NAMES,
28};
29pub use node_toolchain::{
30 find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
31};
32pub use process_monitor_json::{
33 fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
34 CursorProcessMonitorJsonFix,
35};
36pub use js_package_manager::{
37 classify_builds_failure, detect_js_install_package_manager, detect_js_package_manager,
38 ensure_pnpm_workspace_packages, flatten_build_logs_json, is_package_manager_mismatch_error,
39 is_pnpm_packages_field_error, run_package_json_script, suggested_build_command,
40 BuildsFailureClassification, JsPackageManager,
41};
42pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
43pub use project_paths::{
44 canonicalize_for_subprocess, collapse_project_path, resolve_project_dir, resolve_project_path,
45 resolve_service_root,
46};
47pub use version::{fetch_version, increment_version};
48pub use xbp_ignore::{
49 default_global_worktree_ignore_content, default_xbp_ignore_path,
50 load_global_worktree_ignore_from_path, load_project_xbp_ignore, sync_global_worktree_ignore_file,
51 xbp_ignore_file_candidates, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
52 DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS, DEFAULT_NOISE_DIR_NAMES,
53 GLOBAL_WORKTREE_IGNORE_FILENAME,
54};
55
56use serde::de::DeserializeOwned;
57use serde::{Deserialize, Serialize};
58use serde_json::{Map, Value};
59use std::collections::{BTreeMap, HashMap, HashSet};
60use std::fs;
61use std::path::{Path, PathBuf};
62use std::process::Command;
63use sysinfo::{Pid, System};
64
65use crate::profile::find_all_xbp_projects;
66
67#[derive(Debug, Clone)]
68pub struct FoundXbpConfig {
69 pub project_root: PathBuf,
70 pub config_path: PathBuf,
71 pub kind: &'static str,
72 pub location: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct KnownXbpProject {
77 pub root: PathBuf,
78 pub name: String,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct ListeningPortOwnership {
83 pub pids: Vec<u32>,
84 pub xbp_projects: Vec<String>,
85}
86
87pub const PROJECT_CONFIG_FILENAMES: &[&str] = &[
90 "xbp.toml",
91 "xbp.jsonc",
92 "xbp.yaml",
93 "xbp.yml",
94 "xbp.json",
95];
96
97fn project_config_kind_for_filename(name: &str) -> &'static str {
98 match name {
99 "xbp.toml" => "toml",
100 "xbp.jsonc" => "jsonc",
101 "xbp.yaml" | "xbp.yml" => "yaml",
102 "xbp.json" => "json",
103 _ => "yaml",
104 }
105}
106
107pub const PROJECT_CONFIG_HINT: &str =
109 ".xbp/xbp.{toml,jsonc,yaml,yml,json} or xbp.{toml,jsonc,yaml,yml,json}";
110
111pub const DEFAULT_PROJECT_CONFIG_KIND: &str = "toml";
113
114pub const DEFAULT_PROJECT_CONFIG_RELATIVE: &str = ".xbp/xbp.toml";
116
117pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
120 default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND)
121}
122
123pub fn project_config_display_path(project_root: &Path) -> String {
128 find_existing_project_config(project_root)
129 .map(|path| collapse_project_path(project_root, &path.to_string_lossy()))
130 .filter(|rel| !rel.is_empty())
131 .unwrap_or_else(|| DEFAULT_PROJECT_CONFIG_RELATIVE.to_string())
132}
133
134pub fn default_project_config_path(project_root: &Path, kind: &str) -> PathBuf {
136 let filename = match kind {
137 "yaml" | "yml" => "xbp.yaml",
138 "json" => "xbp.json",
139 "jsonc" => "xbp.jsonc",
140 "toml" => "xbp.toml",
141 _ => "xbp.toml",
142 };
143 project_root.join(".xbp").join(filename)
144}
145
146pub fn project_config_candidates(project_root: &Path) -> Vec<(PathBuf, &'static str)> {
148 let mut out = Vec::with_capacity(PROJECT_CONFIG_FILENAMES.len() * 2);
149 for name in PROJECT_CONFIG_FILENAMES {
150 let kind = project_config_kind_for_filename(name);
151 out.push((project_root.join(".xbp").join(name), kind));
152 }
153 for name in PROJECT_CONFIG_FILENAMES {
154 let kind = project_config_kind_for_filename(name);
155 out.push((project_root.join(name), kind));
156 }
157 out
158}
159
160pub fn find_existing_project_config(project_root: &Path) -> Option<PathBuf> {
162 project_config_candidates(project_root)
163 .into_iter()
164 .map(|(path, _)| path)
165 .find(|path| path.exists())
166}
167
168pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
170 let candidates = [
171 project_root.join(".xbp").join("xbp.yaml"),
172 project_root.join(".xbp").join("xbp.yml"),
173 project_root.join("xbp.yaml"),
174 project_root.join("xbp.yml"),
175 ];
176 candidates.into_iter().find(|candidate| candidate.exists())
177}
178
179pub fn resolve_project_config_write_path(
183 project_root: &Path,
184 existing: Option<&FoundXbpConfig>,
185) -> PathBuf {
186 if let Some(found) = existing {
187 return found.config_path.clone();
188 }
189 find_existing_project_config(project_root)
190 .unwrap_or_else(|| default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND))
191}
192
193pub fn serialize_xbp_config<T: Serialize>(value: &T, kind: &str) -> Result<String, String> {
195 let mut json = serde_json::to_value(value)
196 .map_err(|e| format!("Failed to prepare project config: {e}"))?;
197 let _ = ensure_named_entry_key_order(&mut json);
198 render_xbp_config_value(&json, kind)
199}
200
201pub fn write_xbp_project_config_at_path<T: Serialize>(
203 config_path: &Path,
204 value: &T,
205) -> Result<(), String> {
206 let kind = config_kind_from_path(config_path)?;
207 if let Some(parent) = config_path.parent() {
208 fs::create_dir_all(parent).map_err(|e| {
209 format!(
210 "Failed to create config directory {}: {e}",
211 parent.display()
212 )
213 })?;
214 }
215 let rendered = match kind {
216 "yaml" | "yml" => serialize_xbp_yaml_for_path(value, Some(config_path))?,
217 other => serialize_xbp_config(value, other)?,
218 };
219 fs::write(config_path, rendered).map_err(|e| {
220 format!(
221 "Failed to write project config {}: {e}",
222 config_path.display()
223 )
224 })
225}
226
227pub fn load_xbp_project_config_value(
229 config_path: &Path,
230) -> Result<(Value, &'static str, Option<String>), String> {
231 let kind = config_kind_from_path(config_path)?;
232 let content = fs::read_to_string(config_path).map_err(|e| {
233 format!(
234 "Failed to read project config {}: {e}",
235 config_path.display()
236 )
237 })?;
238 let (value, healed) = parse_config_with_auto_heal::<Value>(&content, kind)
239 .map_err(|e| format!("Failed to parse project config {}: {e}", config_path.display()))?;
240 Ok((value, kind, healed))
241}
242
243pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
249 project_root: &Path,
250 config_path: &Path,
251) -> Result<Option<PathBuf>, String> {
252 if let Some(existing) = find_existing_project_config(project_root) {
254 return Ok(Some(existing));
255 }
256 if config_path.exists() {
257 return Ok(Some(config_path.to_path_buf()));
258 }
259 Ok(None)
260}
261
262pub const XBP_PROJECT_YAML_SCHEMA_URL: &str =
268 "https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json";
269
270pub const XBP_PROJECT_YAML_SCHEMA_DIRECTIVE: &str =
274 "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n";
275
276pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
277 serialize_xbp_yaml_for_path(value, None)
278}
279
280pub fn serialize_xbp_yaml_for_path<T: Serialize>(
283 value: &T,
284 config_path: Option<&Path>,
285) -> Result<String, String> {
286 let mut json = serde_json::to_value(value)
289 .map_err(|e| format!("Failed to prepare YAML config: {}", e))?;
290 let _ = ensure_named_entry_key_order(&mut json);
291 let yaml = serde_yaml::to_string(&json)
292 .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
293 let body = normalize_xbp_yaml_environment_quotes(&yaml);
294 let directive = config_path
295 .map(xbp_project_schema_directive_for_path)
296 .unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
297 Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
298}
299
300pub fn is_xbp_project_config_filename(path: &str) -> bool {
302 let normalized = path.replace('\\', "/");
303 let file = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
304 PROJECT_CONFIG_FILENAMES
305 .iter()
306 .any(|name| file.eq_ignore_ascii_case(name))
307}
308
309pub fn xbp_project_schema_directive_for_path(config_path: &Path) -> String {
314 let project_root = config_path
315 .parent()
316 .and_then(|p| {
317 if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
318 p.parent()
319 } else {
320 Some(p)
321 }
322 })
323 .map(Path::to_path_buf);
324
325 if let Some(root) = project_root {
326 let local_schema = root.join("schemas").join("xbp.project.schema.json");
327 if local_schema.is_file() {
328 if let Ok(rel) = pathdiff_relative(config_path.parent().unwrap_or(config_path), &local_schema)
330 {
331 let rel = rel.replace('\\', "/");
332 return format!("# yaml-language-server: $schema={rel}\n");
333 }
334 if config_path
336 .parent()
337 .and_then(|p| p.file_name())
338 == Some(std::ffi::OsStr::new(".xbp"))
339 {
340 return "# yaml-language-server: $schema=../schemas/xbp.project.schema.json\n"
341 .to_string();
342 }
343 return "# yaml-language-server: $schema=./schemas/xbp.project.schema.json\n"
344 .to_string();
345 }
346 }
347
348 XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string()
349}
350
351fn pathdiff_relative(from_dir: &Path, to_file: &Path) -> Result<String, ()> {
353 let from = fs::canonicalize(from_dir).map_err(|_| ())?;
354 let to = fs::canonicalize(to_file).map_err(|_| ())?;
355 let mut from_components: Vec<_> = from.components().collect();
356 let mut to_components: Vec<_> = to.components().collect();
357 while !from_components.is_empty()
359 && !to_components.is_empty()
360 && from_components[0] == to_components[0]
361 {
362 from_components.remove(0);
363 to_components.remove(0);
364 }
365 let mut parts: Vec<String> = from_components.iter().map(|_| "..".to_string()).collect();
366 for c in to_components {
367 parts.push(c.as_os_str().to_string_lossy().into_owned());
368 }
369 if parts.is_empty() {
370 return Err(());
371 }
372 Ok(parts.join("/"))
373}
374
375pub fn ensure_xbp_yaml_schema_directive(yaml: &str) -> String {
377 ensure_xbp_yaml_schema_directive_with(yaml, XBP_PROJECT_YAML_SCHEMA_DIRECTIVE)
378}
379
380pub fn ensure_xbp_yaml_schema_directive_with(yaml: &str, directive: &str) -> String {
382 let directive = if directive.ends_with('\n') {
383 directive.to_string()
384 } else {
385 format!("{directive}\n")
386 };
387 let trimmed = yaml.trim_start_matches('\u{feff}');
388
389 let lines: Vec<&str> = trimmed.lines().collect();
391 let mut body_start = 0usize;
392 if lines.first().is_some_and(|l| l.trim() == "---") {
393 body_start = 1;
394 }
395 while body_start < lines.len() {
396 let line = lines[body_start].trim();
397 if line.is_empty() {
398 body_start += 1;
399 continue;
400 }
401 if line.contains("yaml-language-server") && line.contains("$schema") {
402 body_start += 1;
403 continue;
404 }
405 break;
406 }
407 let body = lines[body_start..].join("\n");
408 let body = if trimmed.ends_with('\n') && !body.ends_with('\n') {
409 format!("{body}\n")
410 } else {
411 body
412 };
413
414 if lines.first().is_some_and(|l| l.trim() == "---") {
415 format!("---\n{directive}{body}")
416 } else {
417 format!("{directive}{body}")
418 }
419}
420
421fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
422 let mut rendered = String::with_capacity(yaml.len());
423 let mut environment_indent: Option<usize> = None;
424
425 for raw_line in yaml.split_inclusive('\n') {
426 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
427 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
428 let indent = line
429 .chars()
430 .take_while(|character| *character == ' ')
431 .count();
432
433 if let Some(active_indent) = environment_indent {
434 if !line.trim().is_empty() && indent <= active_indent {
435 environment_indent = None;
436 }
437 }
438
439 if line.trim() == "environment:" {
440 environment_indent = Some(indent);
441 rendered.push_str(line);
442 rendered.push_str(newline);
443 continue;
444 }
445
446 if let Some(active_indent) = environment_indent {
447 if !line.trim().is_empty() && indent > active_indent {
448 if let Some(rewritten) = rewrite_environment_scalar_line(line) {
449 rendered.push_str(&rewritten);
450 rendered.push_str(newline);
451 continue;
452 }
453 }
454 }
455
456 rendered.push_str(line);
457 rendered.push_str(newline);
458 }
459
460 rendered
461}
462
463fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
464 let (prefix, raw_value) = line.split_once(':')?;
465 let value_fragment = raw_value.trim_start();
466 if value_fragment.is_empty() {
467 return Some(format!("{prefix}: \"\""));
468 }
469
470 let parsed =
471 serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
472 let value = match parsed {
473 serde_yaml::Value::Mapping(mut mapping) => {
474 mapping.remove(serde_yaml::Value::String("value".to_string()))?
475 }
476 _ => return None,
477 };
478
479 let text = match value {
480 serde_yaml::Value::String(text) => text,
481 serde_yaml::Value::Bool(value) => value.to_string(),
482 serde_yaml::Value::Number(value) => value.to_string(),
483 serde_yaml::Value::Null => String::new(),
484 _ => return None,
485 };
486
487 Some(format!(
488 "{prefix}: {}",
489 render_yaml_environment_scalar(&text)
490 ))
491}
492
493fn render_yaml_environment_scalar(value: &str) -> String {
494 if value.is_empty() {
495 return "\"\"".to_string();
496 }
497
498 let contains_control = value
499 .chars()
500 .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
501
502 if !contains_control && value.contains('"') {
503 let escaped = value.replace('\'', "''");
504 format!("'{escaped}'")
505 } else {
506 format!("\"{}\"", escape_yaml_double_quoted(value))
507 }
508}
509
510fn escape_yaml_double_quoted(value: &str) -> String {
511 let mut escaped = String::with_capacity(value.len());
512
513 for character in value.chars() {
514 match character {
515 '\\' => escaped.push_str("\\\\"),
516 '"' => escaped.push_str("\\\""),
517 '\n' => escaped.push_str("\\n"),
518 '\r' => escaped.push_str("\\r"),
519 '\t' => escaped.push_str("\\t"),
520 character if character.is_control() => {
521 use std::fmt::Write as _;
522 let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
523 }
524 character => escaped.push(character),
525 }
526 }
527
528 escaped
529}
530
531pub fn write_json_config_from_any_xbp_config(
532 config_path: &Path,
533 output_json_path: &Path,
534) -> Result<(), String> {
535 let content = fs::read_to_string(config_path)
536 .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
537
538 let kind = config_kind_from_path(config_path)?;
539 let (value, _) = parse_config_with_auto_heal::<Value>(&content, kind)
540 .map_err(|e| format!("Failed to parse config: {}", e))?;
541
542 if let Some(parent) = output_json_path.parent() {
543 fs::create_dir_all(parent).map_err(|e| {
544 format!(
545 "Failed to create config directory {}: {}",
546 parent.display(),
547 e
548 )
549 })?;
550 }
551
552 let rendered = serde_json::to_string_pretty(&value)
553 .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
554 fs::write(output_json_path, rendered).map_err(|e| {
555 format!(
556 "Failed to write JSON config {}: {}",
557 output_json_path.display(),
558 e
559 )
560 })?;
561
562 Ok(())
563}
564
565pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
566 find_xbp_config_at_or_above(start_dir)
567}
568
569pub fn find_xbp_config_in_dir(dir: &Path) -> Option<FoundXbpConfig> {
571 for (path, kind) in project_config_candidates(dir) {
572 if !path.exists() {
573 continue;
574 }
575 let project_root = path
576 .parent()
577 .and_then(|p| {
578 if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
579 p.parent().map(|pp| pp.to_path_buf())
580 } else {
581 Some(p.to_path_buf())
582 }
583 })
584 .unwrap_or_else(|| dir.to_path_buf());
585
586 let location = path
587 .strip_prefix(&project_root)
588 .ok()
589 .map(|p| p.to_string_lossy().replace('\\', "/"))
590 .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
591
592 return Some(FoundXbpConfig {
593 project_root,
594 config_path: path,
595 kind,
596 location,
597 });
598 }
599 None
600}
601
602fn find_xbp_config_at_or_above(start_dir: &Path) -> Option<FoundXbpConfig> {
603 for dir in start_dir.ancestors() {
604 if let Some(found) = find_xbp_config_in_dir(dir) {
605 return Some(found);
606 }
607 }
608 None
609}
610
611pub fn find_repository_xbp_config(start_dir: &Path) -> Option<FoundXbpConfig> {
617 let mut chain: Vec<FoundXbpConfig> = Vec::new();
618 for dir in start_dir.ancestors() {
619 if let Some(found) = find_xbp_config_in_dir(dir) {
620 if chain
622 .iter()
623 .any(|c| c.config_path == found.config_path)
624 {
625 continue;
626 }
627 chain.push(found);
628 }
629 }
630 if chain.is_empty() {
631 return None;
632 }
633
634 for found in &chain {
636 if found.project_root.join(".git").exists() {
637 return Some(found.clone());
638 }
639 }
640
641 chain.pop()
643}
644
645pub fn list_nested_package_xbp_config_paths(repo_root: &Path) -> Result<Vec<PathBuf>, String> {
647 let mut nested_paths: Vec<PathBuf> = Vec::new();
648 collect_nested_xbp_config_paths(repo_root, repo_root, &mut nested_paths)?;
649 nested_paths.sort();
650 nested_paths.dedup();
651 Ok(nested_paths
652 .into_iter()
653 .filter(|path| {
654 let Some(xbp_dir) = path.parent() else {
656 return false;
657 };
658 if xbp_dir.file_name().and_then(|n| n.to_str()) != Some(".xbp") {
659 return false;
660 }
661 let Some(package_root) = xbp_dir.parent() else {
662 return false;
663 };
664 let ca = fs::canonicalize(package_root).unwrap_or_else(|_| package_root.to_path_buf());
665 let cb = fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
666 ca != cb
667 })
668 .collect())
669}
670
671pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
672 let mut projects = Vec::new();
673 let mut seen_roots = HashSet::new();
674
675 for project in find_all_xbp_projects() {
676 let root = canonicalize_or_fallback(&project.path);
677 if seen_roots.insert(root.clone()) {
678 projects.push(KnownXbpProject {
679 root,
680 name: project.name,
681 });
682 }
683 }
684
685 if let Ok(current_dir) = std::env::current_dir() {
686 if let Some(found) = find_xbp_config_upwards(¤t_dir) {
687 let root = canonicalize_or_fallback(&found.project_root);
688 if seen_roots.insert(root.clone()) {
689 let name = root
690 .file_name()
691 .and_then(|value| value.to_str())
692 .unwrap_or("current")
693 .to_string();
694 projects.push(KnownXbpProject { root, name });
695 }
696 }
697 }
698
699 projects.sort_by(|left, right| {
700 right
701 .root
702 .components()
703 .count()
704 .cmp(&left.root.components().count())
705 .then_with(|| left.name.cmp(&right.name))
706 });
707 projects
708}
709
710pub fn resolve_xbp_project_for_path(
711 candidate: &Path,
712 known_projects: &[KnownXbpProject],
713) -> Option<String> {
714 if candidate.as_os_str().is_empty() {
715 return None;
716 }
717
718 if let Some(found) = find_xbp_config_upwards(candidate) {
719 let found_root = canonicalize_or_fallback(&found.project_root);
720 if let Some(project) = known_projects
721 .iter()
722 .find(|project| canonicalize_or_fallback(&project.root) == found_root)
723 {
724 return Some(project.name.clone());
725 }
726
727 if known_projects.is_empty() {
728 return found_root
729 .file_name()
730 .and_then(|value| value.to_str())
731 .map(|value| value.to_string());
732 }
733 }
734
735 let candidate_path = canonicalize_or_fallback(candidate);
736 known_projects
737 .iter()
738 .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
739 .map(|project| project.name.clone())
740}
741
742pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
743 use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
744
745 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
746 let proto_flags = ProtocolFlags::TCP;
747 let sockets = get_sockets_info(af_flags, proto_flags)
748 .map_err(|e| format!("Failed to get sockets info: {}", e))?;
749
750 let mut system = System::new_all();
751 system.refresh_all();
752 let known_projects = collect_known_xbp_projects();
753 let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
754 let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
755
756 for socket in sockets {
757 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
758 let state = format!("{:?}", tcp.state);
759 if state != "Listen" && state != "LISTEN" {
760 continue;
761 }
762
763 let row = ports.entry(tcp.local_port).or_default();
764 for pid in socket.associated_pids {
765 row.pids.push(pid);
766 if let Some(project) = pid_project_cache
767 .entry(pid)
768 .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
769 .clone()
770 {
771 row.xbp_projects.push(project);
772 }
773 }
774 }
775 }
776
777 for row in ports.values_mut() {
778 row.pids.sort_unstable();
779 row.pids.dedup();
780 row.xbp_projects.sort();
781 row.xbp_projects.dedup();
782 }
783
784 Ok(ports)
785}
786
787fn resolve_xbp_project_for_pid(
788 pid: u32,
789 system: &System,
790 known_projects: &[KnownXbpProject],
791) -> Option<String> {
792 let process = system.process(Pid::from_u32(pid))?;
793
794 if let Some(project) = process
795 .cwd()
796 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
797 {
798 return Some(project);
799 }
800
801 process
802 .exe()
803 .and_then(|path| path.parent())
804 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
805}
806
807fn canonicalize_or_fallback(path: &Path) -> PathBuf {
808 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
809}
810
811pub fn expand_home_in_string(input: &str) -> String {
812 let home = dirs::home_dir()
813 .unwrap_or_else(|| std::path::PathBuf::from("."))
814 .to_string_lossy()
815 .to_string();
816
817 if input == "~" {
818 return home;
819 }
820
821 if let Some(rest) = input
822 .strip_prefix("~/")
823 .or_else(|| input.strip_prefix("~\\"))
824 {
825 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
826 }
827
828 if let Some(rest) = input
829 .strip_prefix("$HOME/")
830 .or_else(|| input.strip_prefix("$HOME\\"))
831 {
832 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
833 }
834
835 if let Some(rest) = input
836 .strip_prefix("${HOME}/")
837 .or_else(|| input.strip_prefix("${HOME}\\"))
838 {
839 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
840 }
841
842 input.to_string()
843}
844
845pub fn collapse_home_to_env(input: &str) -> String {
846 let home = dirs::home_dir()
847 .unwrap_or_else(|| std::path::PathBuf::from("."))
848 .to_string_lossy()
849 .to_string();
850
851 if input == home {
852 return "$HOME".to_string();
853 }
854
855 if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
856 return format!("$HOME/{}", rest);
857 }
858
859 if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
860 return format!("$HOME\\{}", rest);
861 }
862
863 input.to_string()
864}
865
866pub fn cargo_program() -> PathBuf {
867 std::env::var_os("CARGO")
868 .map(PathBuf::from)
869 .unwrap_or_else(|| PathBuf::from("cargo"))
870}
871
872pub fn cargo_command_exists() -> bool {
873 let program = cargo_program();
874 if program.is_absolute() {
875 return program.is_file();
876 }
877 command_exists(program.to_string_lossy().as_ref())
878}
879
880pub fn command_exists(program: &str) -> bool {
881 let Some(path_var) = std::env::var_os("PATH") else {
882 return false;
883 };
884
885 for dir in std::env::split_paths(&path_var) {
886 let candidate = dir.join(program);
887 if candidate.is_file() {
888 return true;
889 }
890
891 #[cfg(windows)]
892 for ext in ["exe", "cmd", "bat"] {
893 let candidate = dir.join(format!("{}.{}", program, ext));
894 if candidate.is_file() {
895 return true;
896 }
897 }
898 }
899
900 false
901}
902
903pub fn git_remote_url_from_metadata(
904 project_root: &Path,
905 remote: &str,
906) -> Result<Option<String>, String> {
907 let Some(git_dir) = resolve_git_dir(project_root)? else {
908 return Ok(None);
909 };
910
911 let config_path = git_dir.join("config");
912 if !config_path.exists() {
913 return Ok(None);
914 }
915
916 let content = fs::read_to_string(&config_path)
917 .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
918
919 Ok(parse_git_remote_url_from_config(&content, remote))
920}
921
922pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
923 let normalized: &str = url.trim();
924
925 let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
926 path.to_string()
927 } else if let Some(path) = parse_github_https_repo_path(normalized) {
928 path
929 } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
930 path.to_string()
931 } else {
932 return None;
933 };
934
935 let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
936 let mut segments: std::str::Split<'_, char> = cleaned.split('/');
937 let owner: &str = segments.next()?.trim();
938 let repo: &str = segments.next()?.trim();
939 if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
940 return None;
941 }
942
943 Some((owner.to_string(), repo.to_string()))
944}
945
946pub fn redact_remote_url_credentials(url: &str) -> String {
947 if !url.contains('@') || !url.contains("://") {
948 return url.to_string();
949 }
950 let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
951 Ok(value) => value,
952 Err(_) => return url.to_string(),
953 };
954 if parsed.password().is_some() {
955 let _ = parsed.set_password(Some("REDACTED"));
956 }
957 if !parsed.username().is_empty() {
958 let _ = parsed.set_username("REDACTED");
959 }
960 parsed.to_string()
961}
962
963fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
964 for dir in project_root.ancestors() {
967 if let Some(git_dir) = resolve_git_dir_at(dir)? {
968 return Ok(Some(git_dir));
969 }
970 }
971 Ok(None)
972}
973
974fn resolve_git_dir_at(dir: &Path) -> Result<Option<PathBuf>, String> {
975 let dot_git = dir.join(".git");
976 if dot_git.is_dir() {
977 return Ok(Some(dot_git));
978 }
979
980 if !dot_git.exists() {
981 return Ok(None);
982 }
983
984 let content = fs::read_to_string(&dot_git)
985 .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
986 let git_dir = content
987 .lines()
988 .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
989 .filter(|value| !value.is_empty())
990 .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
991
992 let git_dir_path = PathBuf::from(git_dir);
993 let resolved = if git_dir_path.is_absolute() {
994 git_dir_path
995 } else {
996 dot_git.parent().unwrap_or(dir).join(git_dir_path)
997 };
998
999 Ok(Some(resolved))
1000}
1001
1002fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
1003 let expected_quoted = format!(r#"remote "{}""#, remote);
1004 let expected_dotted = format!("remote.{}", remote);
1005 let mut in_target_section = false;
1006
1007 for line in content.lines() {
1008 let trimmed = line.trim();
1009 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
1010 continue;
1011 }
1012
1013 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1014 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
1015 in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
1016 || section.eq_ignore_ascii_case(&expected_dotted);
1017 continue;
1018 }
1019
1020 if !in_target_section {
1021 continue;
1022 }
1023
1024 let Some((key, value)) = trimmed.split_once('=') else {
1025 continue;
1026 };
1027 if key.trim().eq_ignore_ascii_case("url") {
1028 let value = value.trim();
1029 if !value.is_empty() {
1030 return Some(value.to_string());
1031 }
1032 }
1033 }
1034
1035 None
1036}
1037
1038fn parse_github_https_repo_path(url: &str) -> Option<String> {
1039 let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
1040 if !matches!(parsed.scheme(), "http" | "https") {
1041 return None;
1042 }
1043 if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
1044 return Some(parsed.path().trim_start_matches('/').to_string());
1045 }
1046 None
1047}
1048
1049pub fn first_available_command(candidates: &[&str]) -> Option<String> {
1050 candidates
1051 .iter()
1052 .find(|candidate| command_exists(candidate))
1053 .map(|candidate| (*candidate).to_string())
1054}
1055
1056pub fn preferred_python_command() -> String {
1057 first_available_command(&["python3", "python"]).unwrap_or_else(|| {
1058 if cfg!(target_os = "windows") {
1059 "python".to_string()
1060 } else {
1061 "python3".to_string()
1062 }
1063 })
1064}
1065
1066pub fn preferred_pip_command() -> String {
1067 first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
1068 if cfg!(target_os = "windows") {
1069 "pip".to_string()
1070 } else {
1071 "pip3".to_string()
1072 }
1073 })
1074}
1075
1076pub fn open_with_default_handler(target: &str) -> Result<(), String> {
1077 let mut command = if cfg!(target_os = "windows") {
1078 let mut cmd = Command::new("cmd");
1079 cmd.arg("/C").arg("start").arg("").arg(target);
1080 cmd
1081 } else if cfg!(target_os = "macos") {
1082 let mut cmd = Command::new("open");
1083 cmd.arg(target);
1084 cmd
1085 } else {
1086 let mut cmd = Command::new("xdg-open");
1087 cmd.arg(target);
1088 cmd
1089 };
1090
1091 command
1092 .spawn()
1093 .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
1094 Ok(())
1095}
1096
1097pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
1098 if let Ok(editor) = std::env::var("EDITOR") {
1099 let mut parts = editor.split_whitespace();
1100 let binary = parts
1101 .next()
1102 .ok_or_else(|| "EDITOR is set but empty".to_string())?;
1103 let mut command = Command::new(binary);
1104 for part in parts {
1105 command.arg(part);
1106 }
1107 command
1108 .arg(path)
1109 .spawn()
1110 .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
1111 return Ok(());
1112 }
1113
1114 open_with_default_handler(&path.display().to_string())
1115}
1116
1117#[derive(Debug, Clone, Default, PartialEq, Eq)]
1119struct ConfigParseNormalizeMeta {
1120 had_bom: bool,
1122 multi_document: bool,
1124 duplicate_top_level_keys: bool,
1126 duplicate_nested_keys: bool,
1128 quoted_colon_keys: bool,
1130 salvaged_services: bool,
1132}
1133
1134impl ConfigParseNormalizeMeta {
1135 fn needs_rewrite(&self) -> bool {
1136 self.had_bom
1137 || self.multi_document
1138 || self.duplicate_top_level_keys
1139 || self.duplicate_nested_keys
1140 || self.quoted_colon_keys
1141 || self.salvaged_services
1142 }
1143}
1144
1145fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
1160 let mut output = String::with_capacity(content.len());
1161 let mut changed = false;
1162 let mut first_document = true;
1163
1164 for document in split_yaml_documents(content) {
1165 if !first_document {
1166 if !output.ends_with('\n') && !output.is_empty() {
1167 output.push('\n');
1168 }
1169 output.push_str("---\n");
1170 }
1171 first_document = false;
1172
1173 let (doc_out, doc_changed) = dedupe_yaml_document_top_level_keys(document);
1174 changed |= doc_changed;
1175 output.push_str(&doc_out);
1176 }
1177
1178 if content.ends_with('\n') && !output.ends_with('\n') {
1180 output.push('\n');
1181 }
1182
1183 (output, changed)
1184}
1185
1186fn split_yaml_documents(content: &str) -> Vec<&str> {
1187 let mut documents = Vec::new();
1188 let mut start = 0usize;
1189 let mut offset = 0usize;
1190 for raw_line in content.split_inclusive('\n') {
1191 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1192 let trimmed = line.trim_start();
1193 if offset > start && (trimmed == "---" || trimmed.starts_with("--- ")) {
1194 documents.push(&content[start..offset]);
1195 start = offset + raw_line.len();
1196 }
1197 offset += raw_line.len();
1198 }
1199 documents.push(&content[start..]);
1200 documents
1201}
1202
1203#[derive(Debug, Clone)]
1204struct YamlTopLevelBlock {
1205 key: Option<String>,
1206 text: String,
1207}
1208
1209fn dedupe_yaml_document_top_level_keys(document: &str) -> (String, bool) {
1210 let blocks = split_yaml_top_level_blocks(document);
1211 if blocks.is_empty() {
1212 return (document.to_string(), false);
1213 }
1214
1215 let mut leading = String::new();
1217 let mut keyed: Vec<YamlTopLevelBlock> = Vec::new();
1218 let mut trailing_unkeyed = String::new();
1219 let mut seen_key = false;
1220
1221 for block in blocks {
1222 match &block.key {
1223 None if !seen_key => leading.push_str(&block.text),
1224 None => trailing_unkeyed.push_str(&block.text),
1225 Some(_) => {
1226 seen_key = true;
1227 keyed.push(block);
1228 }
1229 }
1230 }
1231
1232 let mut order: Vec<String> = Vec::new();
1234 let mut chosen: HashMap<String, YamlTopLevelBlock> = HashMap::new();
1235 let mut changed = false;
1236
1237 for block in keyed {
1238 let key = block.key.clone().expect("keyed block");
1239 match chosen.get(&key) {
1240 None => {
1241 order.push(key.clone());
1242 chosen.insert(key, block);
1243 }
1244 Some(existing) => {
1245 changed = true;
1246 if yaml_block_is_better(&key, &block, existing) {
1247 chosen.insert(key, block);
1248 }
1249 }
1250 }
1251 }
1252
1253 if !trailing_unkeyed.is_empty() {
1254 changed = true;
1257 }
1258
1259 if !changed {
1260 return (document.to_string(), false);
1261 }
1262
1263 let mut output = leading;
1264 for key in order {
1265 if let Some(block) = chosen.remove(&key) {
1266 output.push_str(&block.text);
1267 }
1268 }
1269
1270 (output, true)
1271}
1272
1273fn split_yaml_top_level_blocks(document: &str) -> Vec<YamlTopLevelBlock> {
1274 let mut blocks: Vec<YamlTopLevelBlock> = Vec::new();
1275 let mut current_key: Option<String> = None;
1276 let mut current_text = String::new();
1277
1278 let flush = |key: &mut Option<String>, text: &mut String, blocks: &mut Vec<YamlTopLevelBlock>| {
1279 if text.is_empty() {
1280 return;
1281 }
1282 blocks.push(YamlTopLevelBlock {
1283 key: key.take(),
1284 text: std::mem::take(text),
1285 });
1286 };
1287
1288 for raw_line in document.split_inclusive('\n') {
1289 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1290 let trimmed = line.trim_start();
1291 let indent = line.len().saturating_sub(trimmed.len());
1292
1293 let starts_new_key = indent == 0
1294 && !trimmed.is_empty()
1295 && !trimmed.starts_with('#')
1296 && top_level_yaml_key(trimmed).is_some();
1297
1298 if starts_new_key {
1299 flush(&mut current_key, &mut current_text, &mut blocks);
1300 current_key = top_level_yaml_key(trimmed);
1301 current_text.push_str(raw_line);
1302 continue;
1303 }
1304
1305 if indent == 0
1307 && !trimmed.is_empty()
1308 && !trimmed.starts_with('#')
1309 && trimmed.starts_with('-')
1310 && current_key.is_some()
1311 {
1312 current_text.push_str(raw_line);
1313 continue;
1314 }
1315
1316 if current_key.is_some()
1318 || indent > 0
1319 || trimmed.is_empty()
1320 || trimmed.starts_with('#')
1321 {
1322 current_text.push_str(raw_line);
1323 continue;
1324 }
1325
1326 flush(&mut current_key, &mut current_text, &mut blocks);
1328 current_key = None;
1329 current_text.push_str(raw_line);
1330 }
1331
1332 flush(&mut current_key, &mut current_text, &mut blocks);
1333 blocks
1334}
1335
1336fn parse_yaml_top_level_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
1337 let wrapped = format!("{block_text}");
1338 let value: serde_yaml::Value = serde_yaml::from_str(&wrapped).ok()?;
1339 let mapping = value.as_mapping()?;
1340 mapping
1341 .get(serde_yaml::Value::String(key.to_string()))
1342 .cloned()
1343 .or_else(|| {
1344 mapping.iter().find_map(|(k, v)| {
1346 k.as_str()
1347 .filter(|candidate| *candidate == key)
1348 .map(|_| v.clone())
1349 })
1350 })
1351}
1352
1353fn yaml_block_is_better(key: &str, candidate: &YamlTopLevelBlock, existing: &YamlTopLevelBlock) -> bool {
1355 let candidate_value = parse_yaml_top_level_block_value(key, &candidate.text);
1356 let existing_value = parse_yaml_top_level_block_value(key, &existing.text);
1357
1358 match (candidate_value, existing_value) {
1359 (Some(_), None) => true,
1360 (None, Some(_)) => false,
1361 (None, None) => candidate.text.len() > existing.text.len(),
1362 (Some(cand), Some(exist)) => yaml_value_is_richer(&cand, &exist),
1363 }
1364}
1365
1366fn yaml_value_is_richer(candidate: &serde_yaml::Value, existing: &serde_yaml::Value) -> bool {
1367 use serde_yaml::Value as Y;
1368 match (candidate, existing) {
1369 (Y::Null, _) => false,
1370 (_, Y::Null) => true,
1371 (Y::Sequence(c), Y::Sequence(e)) => {
1372 if c.len() != e.len() {
1373 c.len() > e.len()
1374 } else {
1375 yaml_value_complexity(candidate) > yaml_value_complexity(existing)
1376 }
1377 }
1378 (Y::Mapping(c), Y::Mapping(e)) => {
1379 let c_non_null = c.values().filter(|v| !matches!(v, Y::Null)).count();
1380 let e_non_null = e.values().filter(|v| !matches!(v, Y::Null)).count();
1381 if c_non_null != e_non_null {
1382 c_non_null > e_non_null
1383 } else {
1384 yaml_value_complexity(candidate) >= yaml_value_complexity(existing)
1385 }
1386 }
1387 (Y::Sequence(_), _) => true,
1388 (_, Y::Sequence(_)) => false,
1389 (Y::Mapping(_), _) => true,
1390 (_, Y::Mapping(_)) => false,
1391 _ => yaml_value_complexity(candidate) >= yaml_value_complexity(existing),
1392 }
1393}
1394
1395fn yaml_value_complexity(value: &serde_yaml::Value) -> usize {
1396 use serde_yaml::Value as Y;
1397 match value {
1398 Y::Null => 0,
1399 Y::Bool(_) | Y::Number(_) | Y::String(_) => 1,
1400 Y::Sequence(items) => 1 + items.iter().map(yaml_value_complexity).sum::<usize>(),
1401 Y::Mapping(map) => {
1402 1 + map
1403 .iter()
1404 .map(|(k, v)| yaml_value_complexity(k) + yaml_value_complexity(v))
1405 .sum::<usize>()
1406 }
1407 Y::Tagged(tagged) => 1 + yaml_value_complexity(&tagged.value),
1408 }
1409}
1410
1411fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
1412 if trimmed_line.starts_with('-') {
1413 return None;
1414 }
1415 yaml_mapping_key(trimmed_line)
1416}
1417
1418fn yaml_mapping_key(trimmed_line: &str) -> Option<String> {
1421 let without_comment = trimmed_line
1422 .split_once(" #")
1423 .map(|(left, _)| left)
1424 .unwrap_or(trimmed_line)
1425 .trim_end();
1426 let (key, rest) = without_comment.split_once(':')?;
1427 let key = key.trim();
1428 if key.is_empty() || key.contains(' ') || key.contains('\t') {
1429 return None;
1430 }
1431 let key = key
1433 .strip_prefix('"')
1434 .and_then(|k| k.strip_suffix('"'))
1435 .or_else(|| {
1436 key.strip_prefix('\'')
1437 .and_then(|k| k.strip_suffix('\''))
1438 })
1439 .unwrap_or(key);
1440 if rest.starts_with("//") {
1442 return None;
1443 }
1444 Some(key.to_string())
1445}
1446
1447fn line_indent(line: &str) -> usize {
1448 let trimmed = line.trim_start_matches(|c: char| c == ' ' || c == '\t');
1449 line.len().saturating_sub(trimmed.len())
1450}
1451
1452fn line_content(raw: &str) -> &str {
1453 raw.strip_suffix('\n').unwrap_or(raw)
1454}
1455
1456fn dedupe_yaml_nested_mapping_keys(content: &str) -> (String, bool) {
1462 let mut current = content.to_string();
1463 let mut changed = false;
1464
1465 for _ in 0..64 {
1466 match serde_yaml::from_str::<serde_yaml::Value>(¤t) {
1467 Ok(_) => return (current, changed),
1468 Err(error) => {
1469 let message = error.to_string();
1470 if !message.contains("duplicate entry with key") {
1471 return (current, changed);
1472 }
1473 match heal_one_nested_duplicate_mapping_key(¤t, &message) {
1474 Some(next) if next != current => {
1475 current = next;
1476 changed = true;
1477 }
1478 _ => return (current, changed),
1479 }
1480 }
1481 }
1482 }
1483
1484 (current, changed)
1485}
1486
1487fn extract_duplicate_key_error(message: &str) -> Option<(String, usize)> {
1488 let key = {
1490 let marker = "duplicate entry with key \"";
1491 let start = message.find(marker)? + marker.len();
1492 let end = message[start..].find('"')? + start;
1493 message[start..end].to_string()
1494 };
1495 let line = {
1496 let marker = "at line ";
1497 let start = message.find(marker)? + marker.len();
1498 let digits: String = message[start..]
1499 .chars()
1500 .take_while(|c| c.is_ascii_digit())
1501 .collect();
1502 digits.parse::<usize>().ok()?
1503 };
1504 if line == 0 {
1505 return None;
1506 }
1507 Some((key, line))
1508}
1509
1510fn pure_mapping_key_indent_and_name(line: &str) -> Option<(usize, String)> {
1512 let content = line_content(line);
1513 let trimmed = content.trim_start();
1514 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
1515 return None;
1516 }
1517 let indent = line_indent(content);
1518 let key = yaml_mapping_key(trimmed)?;
1519 Some((indent, key))
1520}
1521
1522fn yaml_mapping_key_block_range(
1524 lines: &[String],
1525 key_line_idx: usize,
1526 key_indent: usize,
1527) -> (usize, usize) {
1528 let mut end = key_line_idx + 1;
1529 while end < lines.len() {
1530 let line = line_content(&lines[end]);
1531 let trimmed = line.trim_start();
1532 if trimmed.is_empty() || trimmed.starts_with('#') {
1533 let mut look = end + 1;
1534 while look < lines.len() {
1535 let l = line_content(&lines[look]);
1536 let t = l.trim_start();
1537 if t.is_empty() || t.starts_with('#') {
1538 look += 1;
1539 continue;
1540 }
1541 let i = line_indent(l);
1542 if i > key_indent {
1543 break;
1544 }
1545 return (key_line_idx, end);
1546 }
1547 end += 1;
1548 continue;
1549 }
1550 let indent = line_indent(line);
1551 if indent > key_indent {
1552 end += 1;
1553 continue;
1554 }
1555 break;
1556 }
1557 (key_line_idx, end)
1558}
1559
1560fn same_mapping_scope(
1564 lines: &[String],
1565 first_idx: usize,
1566 second_idx: usize,
1567 key_indent: usize,
1568) -> bool {
1569 if second_idx <= first_idx {
1570 return false;
1571 }
1572 for raw in &lines[first_idx + 1..second_idx] {
1573 let line = line_content(raw);
1574 let trimmed = line.trim_start();
1575 if trimmed.is_empty() || trimmed.starts_with('#') {
1576 continue;
1577 }
1578 if line_indent(line) < key_indent {
1579 return false;
1580 }
1581 if line_indent(line) == key_indent.saturating_sub(2) && trimmed.starts_with('-') {
1583 return false;
1584 }
1585 }
1586 true
1587}
1588
1589fn heal_one_nested_duplicate_mapping_key(content: &str, error_message: &str) -> Option<String> {
1590 let (key, _reported_line) = extract_duplicate_key_error(error_message)?;
1591 let lines: Vec<String> = content
1592 .split_inclusive('\n')
1593 .map(|s| s.to_string())
1594 .collect();
1595 if lines.is_empty() {
1596 return None;
1597 }
1598
1599 let mut occurrences: Vec<(usize, usize)> = Vec::new();
1602 for (idx, line) in lines.iter().enumerate() {
1603 if let Some((indent, name)) = pure_mapping_key_indent_and_name(line) {
1604 if name == key {
1605 occurrences.push((idx, indent));
1606 }
1607 }
1608 }
1609
1610 let mut pair: Option<(usize, usize, usize)> = None; for window in occurrences.windows(2) {
1612 let (i1, ind1) = window[0];
1613 let (i2, ind2) = window[1];
1614 if ind1 != ind2 {
1615 continue;
1616 }
1617 if same_mapping_scope(&lines, i1, i2, ind1) {
1618 pair = Some((i1, i2, ind1));
1619 break;
1620 }
1621 }
1622 let (prev_idx, dup_idx, key_indent) = pair?;
1623
1624 let (prev_start, prev_end) = yaml_mapping_key_block_range(&lines, prev_idx, key_indent);
1625 let (dup_start, dup_end) = yaml_mapping_key_block_range(&lines, dup_idx, key_indent);
1626 if prev_end > dup_start {
1627 return None;
1628 }
1629
1630 let prev_text = lines[prev_start..prev_end].join("");
1631 let dup_text = lines[dup_start..dup_end].join("");
1632 let prev_val = parse_yaml_key_block_value(&key, &prev_text);
1633 let dup_val = parse_yaml_key_block_value(&key, &dup_text);
1634
1635 let merged_value = match (prev_val, dup_val) {
1636 (Some(mut a), Some(b))
1637 if matches!(
1638 (&a, &b),
1639 (serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(_))
1640 ) =>
1641 {
1642 deep_merge_yaml(&mut a, b);
1643 a
1644 }
1645 (Some(a), Some(b)) => {
1646 if yaml_value_is_richer(&b, &a) {
1647 b
1648 } else {
1649 a
1650 }
1651 }
1652 (None, Some(b)) => b,
1653 (Some(a), None) => a,
1654 (None, None) => return None,
1655 };
1656
1657 let indent_str = " ".repeat(key_indent);
1658 let mut map = serde_yaml::Mapping::new();
1659 map.insert(serde_yaml::Value::String(key.clone()), merged_value);
1660 let serialized = serde_yaml::to_string(&serde_yaml::Value::Mapping(map)).ok()?;
1661 let mut rendered = String::new();
1662 for ser_line in serialized.lines() {
1663 if ser_line.trim().is_empty() {
1664 continue;
1665 }
1666 rendered.push_str(&indent_str);
1667 rendered.push_str(ser_line);
1668 rendered.push('\n');
1669 }
1670 if rendered.is_empty() {
1671 return None;
1672 }
1673
1674 let mut output = String::with_capacity(content.len());
1675 for line in &lines[..prev_start] {
1676 output.push_str(line);
1677 }
1678 output.push_str(&rendered);
1679 for line in &lines[prev_end..dup_start] {
1680 output.push_str(line);
1681 }
1682 for line in &lines[dup_end..] {
1683 output.push_str(line);
1684 }
1685 Some(output)
1686}
1687
1688fn parse_yaml_key_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
1689 let value: serde_yaml::Value = serde_yaml::from_str(block_text).ok()?;
1690 let mapping = value.as_mapping()?;
1691 mapping
1692 .get(serde_yaml::Value::String(key.to_string()))
1693 .cloned()
1694 .or_else(|| {
1695 mapping.iter().find_map(|(k, v)| {
1696 k.as_str()
1697 .filter(|candidate| *candidate == key)
1698 .map(|_| v.clone())
1699 })
1700 })
1701}
1702
1703fn parse_yaml_config_documents(
1716 content: &str,
1717) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
1718 let had_bom = content.starts_with('\u{feff}');
1719 let stripped = strip_utf8_bom(content);
1720 let (quoted, quoted_colon_keys) = quote_unquoted_yaml_keys_with_colons(stripped);
1721 let (top_deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys("ed);
1722 let (deduped, duplicate_nested_keys) = dedupe_yaml_nested_mapping_keys(&top_deduped);
1723
1724 match load_yaml_documents(&deduped) {
1725 Ok(documents) => Ok((
1726 finalize_yaml_documents(documents),
1727 ConfigParseNormalizeMeta {
1728 had_bom,
1729 multi_document: documents_len_is_multi(&deduped),
1730 duplicate_top_level_keys,
1731 duplicate_nested_keys,
1732 quoted_colon_keys,
1733 salvaged_services: false,
1734 },
1735 )),
1736 Err(original_error) => {
1737 let (salvaged, salvaged_services) = salvage_yaml_services_sequence(&deduped);
1738 if !salvaged_services {
1739 return Err(original_error);
1740 }
1741 let documents = load_yaml_documents(&salvaged).map_err(|e| {
1742 format!("{original_error}; service salvage also failed: {e}")
1743 })?;
1744 Ok((
1745 finalize_yaml_documents(documents),
1746 ConfigParseNormalizeMeta {
1747 had_bom,
1748 multi_document: documents_len_is_multi(&salvaged),
1749 duplicate_top_level_keys,
1750 duplicate_nested_keys,
1751 quoted_colon_keys,
1752 salvaged_services: true,
1753 },
1754 ))
1755 }
1756 }
1757}
1758
1759fn documents_len_is_multi(content: &str) -> bool {
1760 let mut count = 0usize;
1761 for document in serde_yaml::Deserializer::from_str(content) {
1762 match serde_yaml::Value::deserialize(document) {
1763 Ok(serde_yaml::Value::Null) => {}
1764 Ok(_) => {
1765 count += 1;
1766 if count > 1 {
1767 return true;
1768 }
1769 }
1770 Err(_) => return false,
1771 }
1772 }
1773 false
1774}
1775
1776fn load_yaml_documents(content: &str) -> Result<Vec<serde_yaml::Value>, String> {
1777 let mut documents = Vec::new();
1778 for document in serde_yaml::Deserializer::from_str(content) {
1779 let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
1780 if !matches!(value, serde_yaml::Value::Null) {
1781 documents.push(value);
1782 }
1783 }
1784 Ok(documents)
1785}
1786
1787fn finalize_yaml_documents(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
1788 if documents.is_empty() {
1789 return serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
1790 }
1791 if documents.len() == 1 {
1792 return documents.into_iter().next().unwrap_or(serde_yaml::Value::Null);
1793 }
1794 merge_yaml_values(documents)
1795}
1796
1797fn quote_unquoted_yaml_keys_with_colons(content: &str) -> (String, bool) {
1803 let mut output = String::with_capacity(content.len() + 32);
1804 let mut changed = false;
1805
1806 for raw_line in content.split_inclusive('\n') {
1807 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1808 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
1809 let trimmed = line.trim_start();
1810 let indent = line.len().saturating_sub(trimmed.len());
1811
1812 if trimmed.is_empty() || trimmed.starts_with('#') {
1813 output.push_str(line);
1814 output.push_str(newline);
1815 continue;
1816 }
1817
1818 let (list_prefix, mapping_body) = if let Some(rest) = trimmed.strip_prefix('-') {
1820 let rest = rest.trim_start();
1821 if rest.is_empty() || rest.starts_with('#') {
1822 output.push_str(line);
1823 output.push_str(newline);
1824 continue;
1825 }
1826 let dash_ws_len = trimmed.len() - rest.len();
1827 (&trimmed[..dash_ws_len], rest)
1828 } else {
1829 ("", trimmed)
1830 };
1831
1832 if mapping_body.starts_with('"') || mapping_body.starts_with('\'') {
1833 output.push_str(line);
1834 output.push_str(newline);
1835 continue;
1836 }
1837
1838 if let Some((key, rest)) = split_yaml_mapping_key(mapping_body) {
1839 if key.contains(':') && !key.starts_with('"') && !key.starts_with('\'') {
1840 for _ in 0..indent {
1842 output.push(' ');
1843 }
1844 output.push_str(list_prefix);
1845 output.push('"');
1846 output.push_str(key);
1847 output.push('"');
1848 output.push(':');
1849 output.push_str(rest);
1850 output.push_str(newline);
1851 changed = true;
1852 continue;
1853 }
1854 }
1855
1856 output.push_str(line);
1857 output.push_str(newline);
1858 }
1859
1860 (output, changed)
1861}
1862
1863fn split_yaml_mapping_key(mapping_body: &str) -> Option<(&str, &str)> {
1871 let without_comment = mapping_body
1872 .split_once(" #")
1873 .map(|(left, _)| left)
1874 .unwrap_or(mapping_body);
1875
1876 let bytes = without_comment.as_bytes();
1880 let mut candidates = Vec::new();
1881 for (idx, ch) in without_comment.char_indices() {
1882 if ch != ':' {
1883 continue;
1884 }
1885 let after = idx + 1;
1886 if after >= without_comment.len() {
1887 candidates.push(idx);
1888 continue;
1889 }
1890 let next = bytes[after] as char;
1891 if next.is_whitespace() {
1892 candidates.push(idx);
1893 }
1894 }
1895 let sep = candidates.last().copied().or_else(|| {
1896 without_comment.rfind(':')
1900 })?;
1901
1902 let key = without_comment[..sep].trim_end();
1903 if key.is_empty() || key.contains(' ') || key.contains('\t') {
1904 return None;
1905 }
1906 let rest = &without_comment[sep + 1..];
1907 if !key.contains(':') && rest.starts_with("//") {
1909 return None;
1910 }
1911 Some((key, rest))
1912}
1913
1914fn salvage_yaml_services_sequence(content: &str) -> (String, bool) {
1917 let lines: Vec<&str> = content.split_inclusive('\n').collect();
1918 let mut services_line_idx = None;
1919 for (idx, raw) in lines.iter().enumerate() {
1920 let line = raw.strip_suffix('\n').unwrap_or(raw);
1921 if line == "services:" || line.starts_with("services:") && !line[9..].contains('|') {
1922 if !line.starts_with(' ') && !line.starts_with('\t') {
1924 services_line_idx = Some(idx);
1925 break;
1926 }
1927 }
1928 }
1929 let Some(start) = services_line_idx else {
1930 return (content.to_string(), false);
1931 };
1932
1933 let mut item_starts: Vec<usize> = Vec::new();
1935 let mut end = lines.len();
1936 for idx in (start + 1)..lines.len() {
1937 let line = lines[idx].strip_suffix('\n').unwrap_or(lines[idx]);
1938 let trimmed = line.trim_start();
1939 let indent = line.len().saturating_sub(trimmed.len());
1940 if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
1941 if top_level_yaml_key(trimmed).is_some() {
1942 end = idx;
1943 break;
1944 }
1945 if trimmed.starts_with('-') {
1946 item_starts.push(idx);
1947 }
1948 } else if indent == 0 && trimmed.starts_with('-') {
1949 item_starts.push(idx);
1950 }
1951 }
1952
1953 if item_starts.is_empty() {
1954 return (content.to_string(), false);
1955 }
1956
1957 let mut kept_items: Vec<String> = Vec::new();
1958 let mut dropped = 0usize;
1959 for (i, item_start) in item_starts.iter().enumerate() {
1960 let item_end = item_starts.get(i + 1).copied().unwrap_or(end);
1961 let mut item_text = String::new();
1962 for raw in &lines[*item_start..item_end] {
1963 item_text.push_str(raw);
1964 }
1965 let probe = format!("services:\n{item_text}");
1967 match serde_yaml::from_str::<serde_yaml::Value>(&probe) {
1968 Ok(serde_yaml::Value::Mapping(map)) => {
1969 let ok = map
1970 .get(serde_yaml::Value::String("services".into()))
1971 .and_then(|v| v.as_sequence())
1972 .map(|seq| {
1973 seq.len() == 1
1974 && seq[0]
1975 .as_mapping()
1976 .map(|m| {
1977 m.keys().any(|k| {
1978 k.as_str() == Some("name") || k.as_str() == Some("target")
1979 })
1980 })
1981 .unwrap_or(false)
1982 })
1983 .unwrap_or(false);
1984 if ok {
1985 kept_items.push(item_text);
1986 } else {
1987 dropped += 1;
1988 }
1989 }
1990 _ => dropped += 1,
1991 }
1992 }
1993
1994 if dropped == 0 {
1995 return (content.to_string(), false);
1996 }
1997
1998 let mut output = String::with_capacity(content.len());
1999 for raw in &lines[..=start] {
2000 output.push_str(raw);
2001 }
2002 for item in &kept_items {
2003 output.push_str(item);
2004 }
2005 for raw in &lines[end..] {
2006 output.push_str(raw);
2007 }
2008
2009 (output, true)
2010}
2011
2012fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
2013 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
2014 for document in documents {
2015 deep_merge_yaml(&mut merged, document);
2016 }
2017 merged
2018}
2019
2020fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
2023 match (base, incoming) {
2024 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
2025 for (key, value) in incoming_map {
2026 match base_map.get_mut(&key) {
2027 Some(existing) => deep_merge_yaml(existing, value),
2028 None => {
2029 base_map.insert(key, value);
2030 }
2031 }
2032 }
2033 }
2034 (base_slot, incoming) => {
2035 *base_slot = incoming;
2036 }
2037 }
2038}
2039
2040fn parse_config_value_as_kind(content: &str, kind: &str) -> Result<(Value, bool), String> {
2044 match kind {
2045 "yaml" | "yml" => {
2046 let (yaml_value, meta) = parse_yaml_config_documents(content)?;
2047 let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
2048 Ok((json, meta.needs_rewrite()))
2049 }
2050 "json" => {
2051 let had_bom = content.starts_with('\u{feff}');
2052 let stripped = strip_utf8_bom(content);
2053 let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
2054 Ok((json, had_bom))
2055 }
2056 "jsonc" => {
2057 let stripped = strip_jsonc_comments_light(strip_utf8_bom(content));
2058 let json = serde_json::from_str::<Value>(&stripped).map_err(|e| e.to_string())?;
2059 Ok((json, stripped != content))
2060 }
2061 "toml" => {
2062 let value = toml::from_str::<toml::Value>(strip_utf8_bom(content))
2063 .map_err(|e| e.to_string())?;
2064 let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
2065 Ok((json, false))
2066 }
2067 _ => Err(format!("Unsupported config kind: {kind}")),
2068 }
2069}
2070
2071pub fn detect_config_content_kind(content: &str) -> Option<&'static str> {
2077 let stripped = strip_utf8_bom(content);
2078 let trimmed = stripped.trim_start();
2079 if trimmed.is_empty() {
2080 return None;
2081 }
2082
2083 if (trimmed.starts_with('{') || trimmed.starts_with('['))
2085 && serde_json::from_str::<Value>(trimmed).is_ok()
2086 {
2087 return Some("json");
2088 }
2089
2090 if toml::from_str::<toml::Value>(stripped).is_ok() {
2092 return Some("toml");
2093 }
2094
2095 let jsonc_stripped = strip_jsonc_comments_light(stripped);
2097 if jsonc_stripped != stripped && serde_json::from_str::<Value>(&jsonc_stripped).is_ok() {
2098 return Some("jsonc");
2099 }
2100
2101 if parse_yaml_config_documents(content).is_ok() {
2103 return Some("yaml");
2104 }
2105
2106 None
2107}
2108
2109fn normalize_config_kind_name(kind: &str) -> &str {
2110 match kind {
2111 "yml" => "yaml",
2112 other => other,
2113 }
2114}
2115
2116pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
2122 content: &str,
2123 kind: &str,
2124) -> Result<(T, Option<String>), String> {
2125 let declared = normalize_config_kind_name(kind);
2126 let parse_declared = parse_config_value_as_kind(content, declared);
2127
2128 let (mut value, structural_heal, format_mismatch_from) = match parse_declared {
2129 Ok((value, structural)) => (value, structural, None),
2130 Err(declared_err) => {
2131 let fallback_order = ["toml", "yaml", "json", "jsonc"];
2133 let mut recovered = None;
2134 for candidate in fallback_order {
2135 if candidate == declared {
2136 continue;
2137 }
2138 if let Ok((value, structural)) = parse_config_value_as_kind(content, candidate) {
2139 recovered = Some((value, structural, candidate));
2140 break;
2141 }
2142 }
2143 match recovered {
2144 Some((value, structural, actual_kind)) => {
2145 let _ = structural;
2147 (value, true, Some(actual_kind))
2148 }
2149 None => {
2150 let sniffed = detect_config_content_kind(content)
2151 .map(|k| format!(" (content looks like {k})"))
2152 .unwrap_or_default();
2153 return Err(format!(
2154 "Failed to parse {declared} config{sniffed}: {declared_err}. \
2155 Run `xbp config heal` to recover a mislabeled config body."
2156 ));
2157 }
2158 }
2159 }
2160 };
2161
2162 let field_healed = auto_heal_xbp_config_value(&mut value);
2163 let healed = field_healed || structural_heal || format_mismatch_from.is_some();
2164 let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
2165
2166 let healed_content = if healed {
2167 Some(match declared {
2168 "yaml" => serialize_xbp_yaml(&value)?,
2169 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
2170 "jsonc" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
2171 "toml" => render_xbp_config_value(&value, "toml")?,
2172 _ => return Err(format!("Unsupported config kind: {declared}")),
2173 })
2174 } else {
2175 None
2176 };
2177
2178 Ok((parsed, healed_content))
2179}
2180
2181pub fn config_kind_from_path(path: &Path) -> Result<&'static str, String> {
2182 match path
2183 .extension()
2184 .and_then(|ext| ext.to_str())
2185 .unwrap_or_default()
2186 .to_ascii_lowercase()
2187 .as_str()
2188 {
2189 "yaml" | "yml" => Ok("yaml"),
2190 "json" => Ok("json"),
2191 "jsonc" => Ok("jsonc"),
2192 "toml" => Ok("toml"),
2193 other => Err(format!("Unsupported XBP config extension `{other}`.")),
2194 }
2195}
2196
2197pub fn render_xbp_config_value(value: &Value, kind: &str) -> Result<String, String> {
2198 render_xbp_config_value_for_path(value, kind, None)
2199}
2200
2201pub fn render_xbp_config_value_for_path(
2203 value: &Value,
2204 kind: &str,
2205 config_path: Option<&Path>,
2206) -> Result<String, String> {
2207 let mut value = value.clone();
2208 let _ = ensure_named_entry_key_order(&mut value);
2209 match kind {
2210 "yaml" | "yml" => {
2211 let yaml = serde_yaml::to_string(&value)
2212 .map_err(|e| format!("Failed to serialize YAML config: {e}"))?;
2213 let body = normalize_xbp_yaml_environment_quotes(&yaml);
2214 let directive = config_path
2215 .map(xbp_project_schema_directive_for_path)
2216 .unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
2217 Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
2218 }
2219 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string()),
2220 "jsonc" => {
2221 serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
2223 }
2224 "toml" => {
2225 let toml_value = json_value_to_toml(&value)?;
2226 toml::to_string_pretty(&toml_value).map_err(|e| e.to_string())
2227 }
2228 _ => Err(format!("Unsupported XBP config kind `{kind}`.")),
2229 }
2230}
2231
2232fn json_value_to_toml(value: &Value) -> Result<toml::Value, String> {
2233 match value {
2234 Value::Object(entries) => {
2235 let mut table = toml::map::Map::new();
2236 for (key, value) in entries {
2237 if value.is_null() {
2238 continue;
2239 }
2240 table.insert(key.clone(), json_value_to_toml(value)?);
2241 }
2242 Ok(toml::Value::Table(table))
2243 }
2244 Value::Array(values) => Ok(toml::Value::Array(
2245 values
2246 .iter()
2247 .map(json_value_to_toml)
2248 .collect::<Result<_, _>>()?,
2249 )),
2250 Value::String(value) => Ok(toml::Value::String(value.clone())),
2251 Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
2252 Value::Number(value) => value
2253 .as_i64()
2254 .map(toml::Value::Integer)
2255 .or_else(|| {
2256 value
2257 .as_u64()
2258 .and_then(|number| i64::try_from(number).ok())
2259 .map(toml::Value::Integer)
2260 })
2261 .or_else(|| value.as_f64().map(toml::Value::Float))
2262 .ok_or_else(|| "Unsupported JSON number in TOML conversion.".to_string()),
2263 Value::Null => Err("Null cannot be represented in TOML.".to_string()),
2264 }
2265}
2266
2267pub fn strip_jsonc_comments_light(input: &str) -> String {
2271 let mut output = String::with_capacity(input.len());
2272 let bytes = input.as_bytes();
2273 let mut index = 0usize;
2274 let mut in_string = false;
2275 let mut escaped = false;
2276 while index < bytes.len() {
2277 let character = bytes[index] as char;
2278 if in_string {
2279 output.push(character);
2280 if escaped {
2281 escaped = false;
2282 } else if character == '\\' {
2283 escaped = true;
2284 } else if character == '"' {
2285 in_string = false;
2286 }
2287 index += 1;
2288 continue;
2289 }
2290 if character == '"' {
2291 in_string = true;
2292 output.push(character);
2293 index += 1;
2294 continue;
2295 }
2296 if character == '/' && index + 1 < bytes.len() {
2297 match bytes[index + 1] as char {
2298 '/' => {
2299 index += 2;
2300 while index < bytes.len() && bytes[index] != b'\n' {
2301 index += 1;
2302 }
2303 continue;
2304 }
2305 '*' => {
2306 index += 2;
2307 while index + 1 < bytes.len()
2308 && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
2309 {
2310 index += 1;
2311 }
2312 index = (index + 2).min(bytes.len());
2313 continue;
2314 }
2315 _ => {}
2316 }
2317 }
2318 if character == ',' {
2319 let mut lookahead = index + 1;
2320 while lookahead < bytes.len() && (bytes[lookahead] as char).is_whitespace() {
2321 lookahead += 1;
2322 }
2323 if lookahead < bytes.len() && matches!(bytes[lookahead] as char, '}' | ']') {
2324 index += 1;
2325 continue;
2326 }
2327 }
2328 output.push(character);
2329 index += 1;
2330 }
2331 output
2332}
2333
2334#[derive(Debug, Clone, PartialEq, Eq)]
2335pub struct XbpConfigHealResult {
2336 pub config_path: PathBuf,
2337 pub fixes: Vec<String>,
2338}
2339
2340pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
2341 detect_xbp_config_heal_opportunities_for_kind(content, None)
2342}
2343
2344pub fn detect_xbp_config_heal_opportunities_for_kind(
2346 content: &str,
2347 declared_kind: Option<&str>,
2348) -> Vec<String> {
2349 let mut fixes = Vec::new();
2350 let declared = declared_kind.map(normalize_config_kind_name);
2351
2352 if content.starts_with('\u{feff}') {
2353 fixes.push("strip leading UTF-8 BOM".to_string());
2354 }
2355
2356 if let Some(declared) = declared {
2357 if parse_config_value_as_kind(content, declared).is_err() {
2358 if let Some(actual) = detect_config_content_kind(content) {
2359 if actual != declared {
2360 fixes.push(format!(
2361 "rewrite {actual} body as {declared} (extension/content mismatch)"
2362 ));
2363 }
2364 }
2365 }
2366 }
2367
2368 let run_yaml_structure_checks = match declared {
2371 Some("yaml") => true,
2372 Some("toml" | "json" | "jsonc") => false,
2373 None | Some(_) => matches!(
2375 detect_config_content_kind(content),
2376 Some("yaml") | None
2377 ),
2378 };
2379
2380 if run_yaml_structure_checks {
2381 let stripped = strip_utf8_bom(content);
2382 if content_looks_like_yaml_multidoc(stripped) {
2384 fixes.push("collapse multi-document YAML into a single document".to_string());
2385 }
2386
2387 let mut top_level_keys: HashMap<String, usize> = HashMap::new();
2388 for line in stripped.lines() {
2389 let trimmed = line.trim_start();
2390 if line.len() != trimmed.len() {
2391 continue;
2392 }
2393 if let Some(key) = top_level_yaml_key(trimmed) {
2394 *top_level_keys.entry(key).or_insert(0) += 1;
2395 }
2396 }
2397 let mut duplicate_keys: Vec<_> = top_level_keys
2398 .into_iter()
2399 .filter(|(_, count)| *count > 1)
2400 .map(|(key, count)| format!("resolve {count} duplicate top-level `{key}` blocks"))
2401 .collect();
2402 duplicate_keys.sort();
2403 fixes.extend(duplicate_keys);
2404
2405 let stripped_for_nested = strip_utf8_bom(content);
2408 if serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested)
2409 .ok()
2410 .is_none()
2411 {
2412 let (nested_healed, nested_changed) =
2413 dedupe_yaml_nested_mapping_keys(stripped_for_nested);
2414 if nested_changed
2415 && serde_yaml::from_str::<serde_yaml::Value>(&nested_healed).is_ok()
2416 {
2417 fixes.push(
2418 "resolve duplicate nested mapping keys (e.g. services[].environment)"
2419 .to_string(),
2420 );
2421 } else if let Err(err) =
2422 serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested)
2423 {
2424 let msg = err.to_string();
2425 if msg.contains("duplicate entry with key") {
2426 fixes.push(format!("resolve nested YAML duplicate keys ({msg})"));
2427 }
2428 }
2429 }
2430
2431 for line in content.lines() {
2432 let trimmed = line.trim();
2433 if !trimmed.starts_with("systemd:") {
2434 continue;
2435 }
2436 let value = trimmed.trim_start_matches("systemd:").trim();
2437 if value.is_empty() || value == "null" || value.starts_with('{') {
2438 continue;
2439 }
2440 let normalized = value.trim_matches('"').trim_matches('\'');
2441 fixes.push(format!(
2442 "normalize `systemd: {normalized}` to `systemd_service_name`"
2443 ));
2444 }
2445 }
2446
2447 fixes.sort();
2448 fixes.dedup();
2449 fixes
2450}
2451
2452fn content_looks_like_yaml_multidoc(content: &str) -> bool {
2453 let mut document_count = 0usize;
2454 for document in serde_yaml::Deserializer::from_str(content) {
2455 match serde_yaml::Value::deserialize(document) {
2456 Ok(serde_yaml::Value::Null) => {}
2457 Ok(_) => {
2458 document_count += 1;
2459 if document_count > 1 {
2460 return true;
2461 }
2462 }
2463 Err(_) => return false,
2464 }
2465 }
2466 false
2467}
2468
2469pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
2470 let found = match find_xbp_config_upwards(start_dir) {
2471 Some(found) => found,
2472 None => return Ok(None),
2473 };
2474
2475 heal_config_path(&found.config_path, found.kind)
2476}
2477
2478pub fn heal_config_path(path: &Path, kind: &str) -> Result<Option<XbpConfigHealResult>, String> {
2482 let content = fs::read_to_string(path)
2483 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2484 let mut fixes = detect_xbp_config_heal_opportunities_for_kind(&content, Some(kind));
2485 let healed = heal_config_file(path, kind)?;
2486
2487 if !healed && fixes.is_empty() {
2488 return Ok(None);
2489 }
2490
2491 if fixes.is_empty() {
2492 fixes.push("normalized legacy config fields".to_string());
2493 }
2494
2495 Ok(Some(XbpConfigHealResult {
2496 config_path: path.to_path_buf(),
2497 fixes,
2498 }))
2499}
2500
2501pub fn heal_project_xbp_configs(
2503 start_dir: &Path,
2504 include_nested: bool,
2505) -> Result<Vec<XbpConfigHealResult>, String> {
2506 let mut results = Vec::new();
2507
2508 if let Some(result) = heal_project_xbp_config(start_dir)? {
2509 results.push(result);
2510 }
2511
2512 if !include_nested {
2513 return Ok(results);
2514 }
2515
2516 let root = find_xbp_config_upwards(start_dir)
2517 .map(|f| f.project_root)
2518 .unwrap_or_else(|| start_dir.to_path_buf());
2519
2520 let mut nested_paths: Vec<PathBuf> = Vec::new();
2521 collect_nested_xbp_config_paths(&root, &root, &mut nested_paths)?;
2522 nested_paths.sort();
2523 nested_paths.dedup();
2524
2525 for path in nested_paths {
2526 let kind = config_kind_from_path(&path)?;
2527 if find_xbp_config_upwards(start_dir)
2529 .map(|f| f.config_path == path)
2530 .unwrap_or(false)
2531 {
2532 continue;
2533 }
2534 if let Some(result) = heal_config_path(&path, kind)? {
2535 results.push(result);
2536 }
2537 }
2538
2539 Ok(results)
2540}
2541
2542fn collect_nested_xbp_config_paths(
2543 project_root: &Path,
2544 dir: &Path,
2545 out: &mut Vec<PathBuf>,
2546) -> Result<(), String> {
2547 let entries = match fs::read_dir(dir) {
2548 Ok(entries) => entries,
2549 Err(_) => return Ok(()),
2550 };
2551
2552 for entry in entries.flatten() {
2553 let path = entry.path();
2554 let name = entry.file_name();
2555 let name = name.to_string_lossy();
2556 if path.is_dir() {
2557 if DEFAULT_DISCOVERY_SKIP_DIRS
2558 .iter()
2559 .any(|skip| name.eq_ignore_ascii_case(skip))
2560 || name.starts_with('.') && name != ".xbp"
2561 {
2562 if name == ".xbp" {
2564 collect_nested_xbp_config_paths(project_root, &path, out)?;
2565 }
2566 continue;
2567 }
2568 collect_nested_xbp_config_paths(project_root, &path, out)?;
2569 continue;
2570 }
2571
2572 let file_name = name.as_ref();
2573 if matches!(
2574 file_name,
2575 "xbp.toml" | "xbp.yaml" | "xbp.yml" | "xbp.json" | "xbp.jsonc"
2576 ) {
2577 if path
2579 .parent()
2580 .and_then(|p| p.file_name())
2581 .and_then(|n| n.to_str())
2582 == Some(".xbp")
2583 {
2584 out.push(path);
2585 }
2586 }
2587 }
2588
2589 Ok(())
2590}
2591
2592pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
2593 let content = fs::read_to_string(path)
2594 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2595 let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
2596
2597 if let Some(healed_content) = healed_content {
2598 if healed_content != content {
2599 fs::write(path, healed_content).map_err(|e| {
2600 format!("Failed to write healed config {}: {}", path.display(), e)
2601 })?;
2602 return Ok(true);
2603 }
2604 }
2605
2606 Ok(false)
2607}
2608
2609pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
2610 auto_heal_xbp_config_value(value)
2611}
2612
2613fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
2614 let mut changed = ensure_named_entry_key_order(value);
2615
2616 let Some(root) = value.as_object_mut() else {
2617 return changed;
2618 };
2619
2620 if let Some(environment) = root.get_mut("environment") {
2621 changed |= normalize_environment_value(environment);
2622 }
2623
2624 changed |= normalize_systemd_shorthand(root);
2625
2626 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
2627 for service in services {
2628 let Some(service) = service.as_object_mut() else {
2629 continue;
2630 };
2631
2632 if let Some(environment) = service.get_mut("environment") {
2633 changed |= normalize_environment_value(environment);
2634 }
2635
2636 changed |= normalize_systemd_shorthand(service);
2637 changed |= normalize_boolish_fields(service, SERVICE_BOOL_KEYS);
2638 }
2639 }
2640
2641 changed |= ensure_named_entry_key_order(value);
2643
2644 changed
2645}
2646
2647const SERVICE_BOOL_KEYS: &[&str] = &[
2650 "target_freeze",
2651 "freeze_target",
2652 "target_frozen",
2653 "force_run_from_root",
2654 "versioning",
2655 "versioning_enabled",
2656 "enable_versioning",
2657 "release",
2658 "release_enabled",
2659 "enable_release",
2660];
2661
2662fn normalize_boolish_fields(map: &mut Map<String, Value>, keys: &[&str]) -> bool {
2665 let mut changed = false;
2666 for key in keys {
2667 let Some(value) = map.get_mut(*key) else {
2668 continue;
2669 };
2670 if let Some(coerced) = coerce_boolish_value(value) {
2671 *value = coerced;
2672 changed = true;
2673 }
2674 }
2675 changed
2676}
2677
2678fn coerce_boolish_value(value: &Value) -> Option<Value> {
2681 match value {
2682 Value::Bool(_) => None,
2683 Value::String(raw) => {
2684 let trimmed = raw.trim();
2685 if trimmed.eq_ignore_ascii_case("true")
2686 || trimmed.eq_ignore_ascii_case("yes")
2687 || trimmed.eq_ignore_ascii_case("on")
2688 || trimmed == "1"
2689 {
2690 Some(Value::Bool(true))
2691 } else if trimmed.eq_ignore_ascii_case("false")
2692 || trimmed.eq_ignore_ascii_case("no")
2693 || trimmed.eq_ignore_ascii_case("off")
2694 || trimmed == "0"
2695 {
2696 Some(Value::Bool(false))
2697 } else {
2698 None
2699 }
2700 }
2701 Value::Number(number) => {
2702 if number.as_u64() == Some(1) || number.as_i64() == Some(1) {
2703 Some(Value::Bool(true))
2704 } else if number.as_u64() == Some(0) || number.as_i64() == Some(0) {
2705 Some(Value::Bool(false))
2706 } else {
2707 None
2708 }
2709 }
2710 _ => None,
2711 }
2712}
2713
2714const SERVICE_KEY_ORDER: &[&str] = &[
2717 "name",
2718 "target",
2719 "target_freeze",
2720 "branch",
2721 "port",
2722 "root_directory",
2723 "environment",
2724 "url",
2725 "healthcheck_path",
2726 "restart_policy",
2727 "restart_policy_max_failure_count",
2728 "start_wrapper",
2729 "commands",
2730 "force_run_from_root",
2731 "version_targets",
2732 "depends_on",
2733 "watch_paths",
2734 "versioning",
2735 "release",
2736 "systemd_service_name",
2737 "systemd",
2738 "openapi",
2739 "oci",
2740 "deploy",
2741 "discord",
2742];
2743
2744const WORKER_KEY_ORDER: &[&str] = &[
2746 "name",
2747 "root",
2748 "script_name",
2749 "service",
2750 "deploy",
2751 "container",
2752 "containers",
2753 "durable_objects",
2754];
2755
2756fn ensure_named_entry_key_order(value: &mut Value) -> bool {
2759 let Some(root) = value.as_object_mut() else {
2760 return false;
2761 };
2762 let mut changed = false;
2763
2764 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
2765 for service in services {
2766 if let Some(obj) = service.as_object_mut() {
2767 changed |= reorder_object_keys(obj, SERVICE_KEY_ORDER);
2768 }
2769 }
2770 }
2771
2772 if let Some(workers) = root.get_mut("workers").and_then(Value::as_array_mut) {
2773 for worker in workers {
2774 if let Some(obj) = worker.as_object_mut() {
2775 changed |= reorder_object_keys(obj, WORKER_KEY_ORDER);
2776 }
2777 }
2778 }
2779
2780 changed
2781}
2782
2783fn reorder_object_keys(map: &mut Map<String, Value>, preferred: &[&str]) -> bool {
2786 if map.is_empty() {
2787 return false;
2788 }
2789
2790 let current_keys: Vec<String> = map.keys().cloned().collect();
2791 let mut desired: Vec<String> = Vec::with_capacity(current_keys.len());
2792 for key in preferred {
2793 if map.contains_key(*key) {
2794 desired.push((*key).to_string());
2795 }
2796 }
2797 for key in ¤t_keys {
2798 if !desired.iter().any(|k| k == key) {
2799 desired.push(key.clone());
2800 }
2801 }
2802
2803 if current_keys == desired {
2804 return false;
2805 }
2806
2807 let mut reordered = Map::new();
2808 for key in &desired {
2809 if let Some(value) = map.remove(key) {
2810 reordered.insert(key.clone(), value);
2811 }
2812 }
2813 let leftovers: Vec<(String, Value)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
2815 for (key, value) in leftovers {
2816 map.remove(&key);
2817 reordered.insert(key, value);
2818 }
2819 *map = reordered;
2820 true
2821}
2822
2823fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
2824 let Some(systemd) = map.get("systemd") else {
2825 return false;
2826 };
2827
2828 let Value::String(service_name) = systemd else {
2829 return false;
2830 };
2831
2832 let systemd_service_name_missing = map
2833 .get("systemd_service_name")
2834 .map(|value| value.is_null())
2835 .unwrap_or(true);
2836
2837 if systemd_service_name_missing && !service_name.is_empty() {
2838 map.insert(
2839 "systemd_service_name".to_string(),
2840 Value::String(service_name.clone()),
2841 );
2842 }
2843
2844 map.remove("systemd");
2845 true
2846}
2847
2848fn normalize_environment_value(value: &mut Value) -> bool {
2849 let Value::Object(map) = value else {
2850 return false;
2851 };
2852
2853 let original = map.clone();
2854 let mut normalized = Map::new();
2855 let mut changed = false;
2856
2857 flatten_environment_entries(&original, &mut normalized, &mut changed);
2858
2859 if normalized != original {
2860 *map = normalized;
2861 changed = true;
2862 }
2863
2864 changed
2865}
2866
2867fn flatten_environment_entries(
2868 source: &Map<String, Value>,
2869 target: &mut Map<String, Value>,
2870 changed: &mut bool,
2871) {
2872 for (key, value) in source {
2873 match value {
2874 Value::String(string) => {
2875 let cleaned = unwrap_redundant_env_string_quotes(string);
2876 if cleaned != *string {
2877 *changed = true;
2878 }
2879 target.insert(key.clone(), Value::String(cleaned));
2880 }
2881 Value::Number(number) => {
2882 *changed = true;
2883 target.insert(key.clone(), Value::String(number.to_string()));
2884 }
2885 Value::Bool(boolean) => {
2886 *changed = true;
2887 target.insert(key.clone(), Value::String(boolean.to_string()));
2888 }
2889 Value::Null => {
2890 *changed = true;
2891 target.insert(key.clone(), Value::String(String::new()));
2892 }
2893 Value::Array(items) => {
2894 *changed = true;
2895 let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
2896 target.insert(key.clone(), Value::String(serialized));
2897 }
2898 Value::Object(nested) => {
2899 *changed = true;
2900 flatten_environment_entries(nested, target, changed);
2901 }
2902 }
2903 }
2904}
2905
2906fn unwrap_redundant_env_string_quotes(value: &str) -> String {
2914 let mut current = value.trim().to_string();
2915 for _ in 0..4 {
2916 let next = unwrap_one_env_quote_layer(¤t);
2917 if next == current {
2918 break;
2919 }
2920 current = next;
2921 }
2922 current
2923}
2924
2925fn unwrap_one_env_quote_layer(value: &str) -> String {
2926 let trimmed = value.trim();
2927 if trimmed.len() < 2 {
2928 return trimmed.to_string();
2929 }
2930
2931 if trimmed.starts_with('"') && trimmed.ends_with('"') {
2933 if env_string_is_fully_double_quoted(trimmed) {
2934 let inner = &trimmed[1..trimmed.len() - 1];
2935 return expand_basic_double_quoted_escapes(inner);
2936 }
2937 return trimmed.to_string();
2938 }
2939
2940 if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
2942 if env_string_is_fully_single_quoted(trimmed) {
2945 let inner = &trimmed[1..trimmed.len() - 1];
2946 return inner.replace("''", "'");
2947 }
2948 }
2949
2950 trimmed.to_string()
2951}
2952
2953fn env_string_is_fully_double_quoted(value: &str) -> bool {
2954 if !value.starts_with('"') || value.len() < 2 {
2955 return false;
2956 }
2957 let bytes = value.as_bytes();
2958 let mut i = 1usize;
2959 while i < bytes.len() {
2960 if bytes[i] == b'\\' {
2961 i += 2;
2962 continue;
2963 }
2964 if bytes[i] == b'"' {
2965 return i + 1 == bytes.len();
2966 }
2967 i += 1;
2968 }
2969 false
2970}
2971
2972fn env_string_is_fully_single_quoted(value: &str) -> bool {
2973 if !value.starts_with('\'') || !value.ends_with('\'') || value.len() < 2 {
2974 return false;
2975 }
2976 let inner = &value[1..value.len() - 1];
2978 let mut chars = inner.chars().peekable();
2979 while let Some(ch) = chars.next() {
2980 if ch == '\'' {
2981 if chars.peek() == Some(&'\'') {
2982 chars.next();
2983 continue;
2984 }
2985 return false;
2986 }
2987 }
2988 true
2989}
2990
2991fn expand_basic_double_quoted_escapes(input: &str) -> String {
2992 let mut out = String::with_capacity(input.len());
2993 let mut chars = input.chars().peekable();
2994 while let Some(ch) = chars.next() {
2995 if ch != '\\' {
2996 out.push(ch);
2997 continue;
2998 }
2999 match chars.next() {
3000 Some('n') => out.push('\n'),
3001 Some('r') => out.push('\r'),
3002 Some('t') => out.push('\t'),
3003 Some('\\') => out.push('\\'),
3004 Some('"') => out.push('"'),
3005 Some('\'') => out.push('\''),
3006 Some(other) => out.push(other),
3007 None => out.push('\\'),
3008 }
3009 }
3010 out
3011}
3012
3013#[cfg(test)]
3014mod tests {
3015 use super::{
3016 command_exists, default_project_config_path, detect_xbp_config_heal_opportunities,
3017 find_xbp_config_upwards, first_available_command, git_remote_url_from_metadata,
3018 maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
3019 parse_github_repo_from_remote_url, preferred_pip_command, preferred_python_command,
3020 redact_remote_url_credentials, render_xbp_config_value, resolve_xbp_project_for_path,
3021 write_json_config_from_any_xbp_config, write_xbp_project_config_at_path, KnownXbpProject,
3022 };
3023 use crate::strategies::XbpConfig;
3024 use serde_json::Value;
3025 use std::fs;
3026 use std::path::PathBuf;
3027 use std::sync::{Mutex, OnceLock};
3028
3029 fn path_lock() -> &'static Mutex<()> {
3030 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3031 LOCK.get_or_init(|| Mutex::new(()))
3032 }
3033
3034 fn make_temp_path(name: &str) -> PathBuf {
3035 let mut path = std::env::temp_dir();
3036 path.push(format!("xbp-test-{}-{}", name, std::process::id()));
3037 path
3038 }
3039
3040 fn with_path<F>(entries: &[PathBuf], test: F)
3041 where
3042 F: FnOnce(),
3043 {
3044 let _guard = path_lock().lock().expect("path lock should be available");
3045 let original = std::env::var_os("PATH");
3046 let joined = std::env::join_paths(entries).expect("PATH entries should join");
3047 std::env::set_var("PATH", joined);
3048 test();
3049 match original {
3050 Some(path) => std::env::set_var("PATH", path),
3051 None => std::env::remove_var("PATH"),
3052 }
3053 }
3054
3055 #[test]
3056 fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
3057 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
3059
3060 let (config, healed_content) =
3061 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
3062
3063 assert_eq!(config.project_name, "demo");
3064 assert_eq!(config.port, 3000);
3065 let healed = healed_content.expect("BOM should trigger autonomous rewrite");
3066 assert!(
3067 !healed.starts_with('\u{feff}'),
3068 "healed yaml must not retain BOM"
3069 );
3070 assert!(healed.contains("project_name: demo"));
3071 }
3072
3073 #[test]
3074 fn heals_duplicate_nested_environment_under_service() {
3075 let yaml = r#"
3078project_name: demo
3079version: 1.0.0
3080port: 3000
3081build_dir: ./
3082services:
3083- name: app
3084 target: nextjs
3085 branch: main
3086 port: 3000
3087 environment:
3088 A: "1"
3089 SHARED: first
3090 root_directory: ./
3091 environment:
3092 B: "2"
3093 SHARED: second
3094"#;
3095
3096 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3097 .expect("duplicate nested environment should heal");
3098 let services = config.services.expect("services");
3099 assert_eq!(services.len(), 1);
3100 let env = services[0]
3101 .environment
3102 .as_ref()
3103 .expect("merged environment");
3104 assert_eq!(env.get("A"), Some(&"1".to_string()));
3105 assert_eq!(env.get("B"), Some(&"2".to_string()));
3106 assert_eq!(
3107 env.get("SHARED"),
3108 Some(&"second".to_string()),
3109 "later environment block should win on scalar conflicts"
3110 );
3111
3112 let healed = healed_content.expect("should rewrite healed yaml");
3113 let env_keys_in_first_service = healed
3114 .lines()
3115 .filter(|line| *line == " environment:" || line.starts_with(" environment: "))
3116 .count();
3117 assert_eq!(
3118 env_keys_in_first_service, 1,
3119 "exactly one environment key under the service: {healed}"
3120 );
3121 assert!(healed.contains("A:") || healed.contains("A: "));
3122 assert!(healed.contains("B:") || healed.contains("B: "));
3123 }
3124
3125 #[test]
3126 fn discovers_and_round_trips_all_project_config_formats() {
3127 let root = make_temp_path("multi-format-config");
3128 let _ = fs::remove_dir_all(&root);
3129 fs::create_dir_all(root.join(".xbp")).expect("mkdir");
3130
3131 let baseline = serde_json::json!({
3132 "project_name": "multi",
3133 "port": 3000,
3134 "build_dir": "./",
3135 "services": [{
3136 "name": "api",
3137 "target": "rust",
3138 "branch": "main",
3139 "port": 8080
3140 }]
3141 });
3142
3143 for kind in ["yaml", "json", "jsonc", "toml"] {
3144 let path = default_project_config_path(&root, kind);
3145 write_xbp_project_config_at_path(&path, &baseline).expect("write");
3146 assert!(path.exists(), "{kind} path missing");
3147
3148 let found = find_xbp_config_upwards(&root).expect("discover");
3149 assert_eq!(found.kind, if kind == "yml" { "yaml" } else { kind });
3150 assert_eq!(found.config_path, path);
3151
3152 let content = fs::read_to_string(&path).expect("read");
3153 let (parsed, _) =
3154 parse_config_with_auto_heal::<Value>(&content, found.kind).expect("parse");
3155 assert_eq!(parsed["project_name"], "multi");
3156 assert_eq!(parsed["services"][0]["name"], "api");
3157
3158 write_xbp_project_config_at_path(&path, &parsed).expect("rewrite");
3160 let found_again = find_xbp_config_upwards(&root).expect("rediscover");
3161 assert_eq!(found_again.config_path, path);
3162
3163 let _ = fs::remove_file(&path);
3164 }
3165
3166 let _ = fs::remove_dir_all(&root);
3167 }
3168
3169 #[test]
3170 fn service_name_key_is_first_in_yaml_and_toml() {
3171 let value = serde_json::json!({
3174 "project_name": "demo",
3175 "port": 3000,
3176 "build_dir": "./",
3177 "services": [{
3178 "branch": "main",
3179 "port": 3000,
3180 "target": "nextjs",
3181 "name": "app",
3182 "root_directory": "./"
3183 }],
3184 "workers": [{
3185 "root": "apps/api",
3186 "name": "api"
3187 }]
3188 });
3189
3190 for kind in ["yaml", "toml", "json"] {
3191 let rendered = render_xbp_config_value(&value, kind).expect("render");
3192 match kind {
3193 "yaml" => {
3194 let mut saw_service_item = false;
3196 let mut first_key: Option<String> = None;
3197 for line in rendered.lines() {
3198 let trimmed = line.trim_start();
3199 if trimmed.starts_with("- ") || trimmed.starts_with("- name:") {
3200 saw_service_item = true;
3201 if let Some(rest) = trimmed.strip_prefix("- ") {
3202 if let Some((key, _)) = rest.split_once(':') {
3203 first_key = Some(key.trim().to_string());
3204 break;
3205 }
3206 }
3207 continue;
3208 }
3209 if saw_service_item && !trimmed.is_empty() && !trimmed.starts_with('#') {
3210 if let Some((key, _)) = trimmed.split_once(':') {
3211 first_key = Some(key.trim().to_string());
3212 break;
3213 }
3214 }
3215 }
3216 assert_eq!(
3217 first_key.as_deref(),
3218 Some("name"),
3219 "yaml service first key should be name:\n{rendered}"
3220 );
3221 }
3222 "toml" => {
3223 let services_idx = rendered.find("[[services]]").expect("services table");
3225 let after = &rendered[services_idx..];
3226 let name_idx = after.find("name = ").expect("name key");
3227 let branch_idx = after.find("branch = ").expect("branch key");
3228 assert!(
3229 name_idx < branch_idx,
3230 "toml services name must precede branch:\n{rendered}"
3231 );
3232 }
3233 "json" => {
3234 let parsed: Value =
3235 serde_json::from_str(&rendered).expect("json parse");
3236 let keys: Vec<&str> = parsed["services"][0]
3237 .as_object()
3238 .expect("service object")
3239 .keys()
3240 .map(|k| k.as_str())
3241 .collect();
3242 assert_eq!(keys.first().copied(), Some("name"), "json keys: {keys:?}");
3243 }
3244 _ => {}
3245 }
3246 }
3247 }
3248
3249 #[test]
3250 fn heals_double_quoted_environment_string_values() {
3251 let yaml = r#"
3253project_name: demo
3254version: 1.0.0
3255port: 3000
3256build_dir: ./
3257environment:
3258 APPLE_CLIENT_ID: '"com.suitsconnect"'
3259 ATHENA_URL: '"https://mirror1.athena-cluster.com"'
3260 PLACEHOLDER: ${DATABASE_URL}
3261 JSON_OK: '{"a":1}'
3262services:
3263- name: app
3264 target: nextjs
3265 branch: main
3266 port: 3000
3267 environment:
3268 BETTER_AUTH_SECRET: '"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e"'
3269 BARE: plain
3270"#;
3271
3272 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3273 .expect("double-quoted env values should heal");
3274 let root_env = config.environment.expect("root environment");
3275 assert_eq!(
3276 root_env.get("APPLE_CLIENT_ID"),
3277 Some(&"com.suitsconnect".to_string())
3278 );
3279 assert_eq!(
3280 root_env.get("ATHENA_URL"),
3281 Some(&"https://mirror1.athena-cluster.com".to_string())
3282 );
3283 assert_eq!(
3284 root_env.get("PLACEHOLDER"),
3285 Some(&"${DATABASE_URL}".to_string())
3286 );
3287 assert_eq!(root_env.get("JSON_OK"), Some(&r#"{"a":1}"#.to_string()));
3289
3290 let svc_env = config
3291 .services
3292 .as_ref()
3293 .and_then(|s| s.first())
3294 .and_then(|s| s.environment.as_ref())
3295 .expect("service environment");
3296 assert_eq!(
3297 svc_env.get("BETTER_AUTH_SECRET"),
3298 Some(&"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e".to_string())
3299 );
3300 assert_eq!(svc_env.get("BARE"), Some(&"plain".to_string()));
3301
3302 let healed = healed_content.expect("quote unwrap should rewrite config");
3303 assert!(
3304 !healed.contains("'\"com.suitsconnect\"'"),
3305 "healed yaml must not keep nested quote wrap: {healed}"
3306 );
3307 assert!(
3308 healed.contains("com.suitsconnect"),
3309 "healed yaml should keep unwrapped value: {healed}"
3310 );
3311 }
3312
3313 #[test]
3314 fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
3315 let yaml = r#"
3316project_name: athena
3317version: 1.0.0
3318port: 4052
3319build_dir: ./
3320openapi:
3321 enabled: true
3322 auto_detect_services: false
3323services:
3324 - name: athena
3325 target: rust
3326 branch: main
3327 port: 4052
3328 openapi: null
3329openapi: null
3330workers:
3331 - name: athena-auth
3332 root: services/athena-auth
3333"#;
3334
3335 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3336 .expect("duplicate openapi key should be healed");
3337 assert_eq!(config.project_name, "athena");
3338 assert_eq!(
3339 config.openapi.as_ref().and_then(|o| o.enabled),
3340 Some(true)
3341 );
3342 assert!(config.workers.is_some());
3343 let healed = healed_content.expect("duplicate key should trigger rewrite");
3344 let top_level_openapi_lines = healed
3345 .lines()
3346 .filter(|line| line.starts_with("openapi:"))
3347 .count();
3348 assert_eq!(
3349 top_level_openapi_lines, 1,
3350 "healed yaml should keep a single top-level openapi block: {healed}"
3351 );
3352 assert!(
3353 !healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
3354 "stray top-level openapi: null must be dropped: {healed}"
3355 );
3356 }
3357
3358 #[test]
3359 fn heals_duplicate_services_keeping_richer_column0_sequence() {
3360 let yaml = r#"
3363project_name: athena
3364version: 4.0.2
3365port: 4052
3366build_dir: ./
3367environment: {}
3368services:
3369- name: athena
3370 target: docker-compose
3371 branch: main
3372 port: 3001
3373 npm:
3374 enabled: true
3375 package_name: '@xylex-group/athena'
3376release_disabled: []
3377services:
3378- branch: main
3379 name: athena
3380 target: docker-compose
3381 port: 3001
3382 commands:
3383 build: cargo build --release
3384 start: cargo run
3385- branch: main
3386 name: athena-studio
3387 target: nextjs
3388 port: 3000
3389version: 4.0.2
3390release_disabled: []
3391"#;
3392
3393 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3394 .expect("duplicate services with column-0 list items should heal");
3395 assert_eq!(config.project_name, "athena");
3396 let services = config.services.expect("services should load");
3397 assert_eq!(services.len(), 2, "should keep the fuller services list");
3398 assert_eq!(services[0].name, "athena");
3399 assert_eq!(services[1].name, "athena-studio");
3400 let healed = healed_content.expect("duplicate services should rewrite");
3401 assert_eq!(
3402 healed.lines().filter(|line| line.starts_with("services:")).count(),
3403 1,
3404 "exactly one services key: {healed}"
3405 );
3406 assert!(healed.contains("athena-studio"));
3407 assert!(
3408 !healed.contains("package_name: '@xylex-group/athena'")
3409 || healed.contains("athena-studio"),
3410 "richer second services block must win"
3411 );
3412 }
3413
3414 #[test]
3415 fn heals_unquoted_colon_command_keys_and_broken_service_items() {
3416 let yaml = r#"
3417project_name: demo
3418version: 1.0.0
3419port: 3000
3420build_dir: ./
3421services:
3422- name: api
3423 target: rust
3424 branch: main
3425 port: 3001
3426 commands:
3427 build: cargo build
3428 deploy:production: cargo run --release
3429- branch: main
3430 format: npm run format
3431 lint: npm run lint
3432- name: web
3433 target: nextjs
3434 branch: main
3435 port: 3000
3436 commands:
3437 start: pnpm start
3438"#;
3439
3440 let (config, healed) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3441 .expect("colon keys + broken service item should heal");
3442 let services = config.services.expect("services");
3443 assert_eq!(services.len(), 2);
3444 assert_eq!(services[0].name, "api");
3445 assert_eq!(services[1].name, "web");
3446 let commands = services[0].commands.as_ref().expect("api commands");
3447 assert!(
3448 commands.custom.contains_key("deploy:production"),
3449 "quoted colon key should load as deploy:production: {commands:?}"
3450 );
3451 let healed = healed.expect("should rewrite healed yaml");
3452 assert!(healed.contains("\"deploy:production\"") || healed.contains("deploy:production"));
3453 assert!(!healed.contains("\n format: npm run format\n"));
3454 }
3455
3456 #[test]
3457 fn merges_multi_document_yaml_and_rewrites_single_document() {
3458 let yaml = r#"
3459project_name: demo
3460port: 3000
3461build_dir: $HOME/demo
3462---
3463environment:
3464 LOG_LEVEL: info
3465---
3466services:
3467 - name: api
3468 target: rust
3469 branch: main
3470 port: 3001
3471"#;
3472
3473 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3474 .expect("multi-document yaml should merge");
3475
3476 assert_eq!(config.project_name, "demo");
3477 assert_eq!(config.port, 3000);
3478 let environment = config.environment.expect("merged environment should exist");
3479 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
3480 let services = config.services.expect("merged services should exist");
3481 assert_eq!(services.len(), 1);
3482 assert_eq!(services[0].name, "api");
3483
3484 let healed = healed_content.expect("multi-doc should trigger rewrite");
3485 assert!(
3486 !healed.lines().any(|line| line.trim() == "---"),
3487 "healed yaml should be a single document"
3488 );
3489 assert!(healed.contains("project_name: demo"));
3490 assert!(healed.contains("LOG_LEVEL"));
3491 assert!(healed.contains("name: api"));
3492 }
3493
3494 #[test]
3495 fn parses_bom_prefixed_multi_document_yaml() {
3496 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
3497
3498 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3499 .expect("BOM + multi-doc yaml should parse");
3500
3501 assert_eq!(config.project_name, "demo");
3502 assert_eq!(config.version, "1.2.3");
3503 assert!(healed_content.is_some());
3504 }
3505
3506 #[test]
3507 fn ensure_xbp_yaml_schema_directive_is_idempotent() {
3508 let bare = "project_name: demo\nport: 3000\nbuild_dir: .\n";
3509 let once = super::ensure_xbp_yaml_schema_directive(bare);
3510 assert!(once.starts_with("# yaml-language-server: $schema="));
3511 assert!(once.contains("project_name: demo"));
3512 let twice = super::ensure_xbp_yaml_schema_directive(&once);
3513 assert_eq!(
3514 once.matches("yaml-language-server").count(),
3515 1,
3516 "should not duplicate schema directive"
3517 );
3518 assert_eq!(twice, once);
3519
3520 let with_doc = "---\nproject_name: demo\n";
3521 let headed = super::ensure_xbp_yaml_schema_directive(with_doc);
3522 assert!(headed.starts_with("---\n# yaml-language-server:"));
3523 }
3524
3525 #[test]
3526 fn rewrites_legacy_xbp_repo_schema_url_to_mirrors() {
3527 let legacy = concat!(
3529 "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json\n",
3530 "project_name: demo\nport: 3000\nbuild_dir: .\n",
3531 );
3532 let fixed = super::ensure_xbp_yaml_schema_directive(legacy);
3533 assert!(
3534 fixed.contains(super::XBP_PROJECT_YAML_SCHEMA_URL),
3535 "should rewrite to xbp-mirrors schema URL: {fixed}"
3536 );
3537 assert!(
3538 fixed.contains("xbp-mirrors"),
3539 "canonical remote is xbp-mirrors: {fixed}"
3540 );
3541 assert!(
3542 !fixed.contains("xylex-group/xbp/main/schemas"),
3543 "legacy repo-path schema URL must be replaced: {fixed}"
3544 );
3545 assert_eq!(fixed.matches("yaml-language-server").count(), 1);
3546
3547 let mirrors = concat!(
3549 "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n",
3550 "project_name: demo\nport: 3000\nbuild_dir: .\n",
3551 );
3552 let kept = super::ensure_xbp_yaml_schema_directive(mirrors);
3553 assert_eq!(kept, mirrors);
3554 }
3555
3556 #[test]
3557 fn local_schema_directive_for_dot_xbp_config() {
3558 let root = make_temp_path("local-schema-dir");
3559 let _ = fs::remove_dir_all(&root);
3560 fs::create_dir_all(root.join("schemas")).expect("schemas");
3561 fs::create_dir_all(root.join(".xbp")).expect(".xbp");
3562 fs::write(
3563 root.join("schemas").join("xbp.project.schema.json"),
3564 r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#,
3565 )
3566 .expect("schema file");
3567 let config_path = root.join(".xbp").join("xbp.yaml");
3568 let directive = super::xbp_project_schema_directive_for_path(&config_path);
3569 assert!(
3570 directive.contains("../schemas/xbp.project.schema.json")
3571 || directive.contains("schemas/xbp.project.schema.json"),
3572 "expected relative local schema, got {directive}"
3573 );
3574 let _ = fs::remove_dir_all(&root);
3575 }
3576
3577 #[test]
3578 fn detect_heal_opportunities_reports_bom_and_multidoc() {
3579 let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
3580 let fixes = detect_xbp_config_heal_opportunities(yaml);
3581 assert!(
3582 fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
3583 "expected BOM fix, got {fixes:?}"
3584 );
3585 assert!(
3586 fixes.iter().any(|fix| fix.contains("multi-document")),
3587 "expected multi-doc fix, got {fixes:?}"
3588 );
3589 assert!(
3590 fixes.iter().any(|fix| fix.contains("systemd")),
3591 "expected systemd fix, got {fixes:?}"
3592 );
3593 }
3594
3595 #[test]
3596 fn heal_config_file_strips_bom_on_disk() {
3597 let project_root = make_temp_path("heal-bom-yaml");
3598 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3599 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
3600 fs::write(
3601 &yaml_path,
3602 "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
3603 )
3604 .expect("yaml should be written with BOM");
3605
3606 let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
3607 assert!(healed, "BOM config should be rewritten");
3608
3609 let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
3610 assert!(
3611 !rewritten.starts_with('\u{feff}'),
3612 "rewritten file must not start with BOM"
3613 );
3614 assert!(rewritten.contains("project_name: demo"));
3615
3616 let (config, second_heal) =
3618 parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
3619 assert_eq!(config.project_name, "demo");
3620 assert!(second_heal.is_none());
3623
3624 fs::remove_dir_all(project_root).expect("temp project should be removed");
3625 }
3626
3627 #[test]
3628 fn heals_nested_yaml_environment_blocks() {
3629 let yaml = r#"
3630project_name: demo
3631port: 3000
3632build_dir: $HOME/demo
3633environment:
3634 production:
3635 DATABASE_URL: postgres://localhost/demo
3636 LOG_LEVEL: info
3637services:
3638 - name: api
3639 target: rust
3640 branch: main
3641 port: 3001
3642 environment:
3643 production:
3644 SERVICE_TOKEN: abc123
3645"#;
3646
3647 let (config, healed_content) =
3648 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3649
3650 let environment = config.environment.expect("top-level env should exist");
3651 assert_eq!(
3652 environment.get("DATABASE_URL"),
3653 Some(&"postgres://localhost/demo".to_string())
3654 );
3655 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
3656
3657 let service_environment = config.services.expect("services should exist")[0]
3658 .environment
3659 .clone()
3660 .expect("service env should exist");
3661 assert_eq!(
3662 service_environment.get("SERVICE_TOKEN"),
3663 Some(&"abc123".to_string())
3664 );
3665 assert!(healed_content.is_some());
3666 }
3667
3668 #[test]
3669 fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
3670 let yaml = r#"
3671services:
3672 - name: api
3673 systemd: athena.service
3674 systemd_service_name: athena
3675"#;
3676 let fixes = detect_xbp_config_heal_opportunities(yaml);
3677 assert_eq!(fixes.len(), 1);
3678 assert!(fixes[0].contains("athena.service"));
3679 }
3680
3681 #[test]
3682 fn heals_systemd_string_shorthand_in_yaml() {
3683 let yaml = r#"
3684project_name: athena
3685port: 3000
3686build_dir: $HOME/athena
3687systemd: athena.service
3688services:
3689 - name: api
3690 target: rust
3691 branch: main
3692 port: 3001
3693 systemd: athena-api.service
3694"#;
3695
3696 let (config, healed_content) =
3697 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3698
3699 assert_eq!(
3700 config.systemd_service_name.as_deref(),
3701 Some("athena.service")
3702 );
3703 assert!(config.systemd.is_none());
3704
3705 let service = &config.services.expect("services should exist")[0];
3706 assert_eq!(
3707 service.systemd_service_name.as_deref(),
3708 Some("athena-api.service")
3709 );
3710 assert!(service.systemd.is_none());
3711 assert!(healed_content.is_some());
3712 assert!(!healed_content
3713 .expect("healed content should exist")
3714 .contains("systemd: athena"));
3715 }
3716
3717 #[test]
3718 fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
3719 let yaml = r#"
3720project_name: athena
3721port: 4052
3722build_dir: $HOME/athena
3723services:
3724 - name: athena-gateway
3725 target: rust
3726 branch: main
3727 port: 4052
3728 systemd: athena.service
3729 systemd_service_name: athena
3730"#;
3731
3732 let (config, healed_content) =
3733 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3734
3735 let service = &config.services.expect("services should exist")[0];
3736 assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
3737 assert!(service.systemd.is_none());
3738 assert!(healed_content.is_some());
3739 }
3740
3741 #[test]
3742 fn heals_non_string_environment_values_in_json() {
3743 let json = r#"{
3744 "project_name": "demo",
3745 "port": 3000,
3746 "build_dir": "$HOME/demo",
3747 "environment": {
3748 "PORT": 3000,
3749 "DEBUG": true,
3750 "EMPTY": null
3751 }
3752}"#;
3753
3754 let (config, healed_content) =
3755 parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
3756
3757 let environment = config.environment.expect("top-level env should exist");
3758 assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
3759 assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
3760 assert_eq!(environment.get("EMPTY"), Some(&String::new()));
3761 assert!(healed_content.is_some());
3762 }
3763
3764 #[test]
3765 fn heals_string_bool_fields_in_toml_services() {
3766 let toml = r#"
3768project_name = "demo"
3769port = 3000
3770build_dir = "./"
3771
3772[[services]]
3773name = "app"
3774target = "rust"
3775target_freeze = "true"
3776branch = "main"
3777port = 3000
3778force_run_from_root = "false"
3779versioning = "yes"
3780release = "0"
3781"#;
3782
3783 let (config, healed_content) =
3784 parse_config_with_auto_heal::<XbpConfig>(toml, "toml").expect("string bools should heal");
3785
3786 let service = &config.services.expect("services")[0];
3787 assert_eq!(service.target_freeze, Some(true));
3788 assert_eq!(service.force_run_from_root, Some(false));
3789 assert_eq!(service.versioning, Some(true));
3790 assert_eq!(service.release, Some(false));
3791 let healed = healed_content.expect("heal should rewrite quoted bools");
3792 assert!(
3793 healed.contains("target_freeze = true")
3794 || healed.contains("target_freeze=true"),
3795 "healed TOML should emit unquoted bools: {healed}"
3796 );
3797 assert!(!healed.contains(r#"target_freeze = "true""#));
3798 }
3799
3800 #[test]
3801 fn heals_yaml_body_saved_as_toml_extension() {
3802 let yaml_as_toml = r#"# yaml-language-server: $schema=https://example.com/schema.json
3804project_name: athena
3805version: 4.0.2
3806port: 4052
3807build_dir: ./
3808services:
3809 - name: api
3810 target: rust
3811 branch: main
3812 port: 3001
3813"#;
3814
3815 assert_eq!(super::detect_config_content_kind(yaml_as_toml), Some("yaml"));
3816 let fixes = super::detect_xbp_config_heal_opportunities_for_kind(yaml_as_toml, Some("toml"));
3817 assert!(
3818 fixes.iter().any(|f| f.contains("mismatch") || f.contains("yaml")),
3819 "expected format-mismatch fix, got {fixes:?}"
3820 );
3821
3822 let (config, healed_content) =
3823 parse_config_with_auto_heal::<XbpConfig>(yaml_as_toml, "toml")
3824 .expect("yaml-in-toml should recover");
3825 assert_eq!(config.project_name, "athena");
3826 assert_eq!(config.version, "4.0.2");
3827 let healed = healed_content.expect("should rewrite as real TOML");
3828 assert!(
3829 healed.contains("project_name = \"athena\"")
3830 || healed.contains("project_name=\"athena\""),
3831 "healed body must be TOML: {healed}"
3832 );
3833 assert!(
3834 !healed.contains("project_name: athena"),
3835 "YAML key syntax must not remain: {healed}"
3836 );
3837
3838 let dir = make_temp_path("yaml-as-toml");
3840 fs::create_dir_all(dir.join(".xbp")).expect("xbp dir");
3841 let path = dir.join(".xbp").join("xbp.toml");
3842 fs::write(&path, yaml_as_toml).expect("write broken config");
3843 let result = super::heal_config_path(&path, "toml")
3844 .expect("heal")
3845 .expect("should report heal");
3846 assert!(!result.fixes.is_empty());
3847 let rewritten = fs::read_to_string(&path).expect("read healed");
3848 assert!(
3849 rewritten.contains("project_name")
3850 && rewritten.contains('=')
3851 && !rewritten.contains("project_name: athena"),
3852 "disk heal must write TOML: {rewritten}"
3853 );
3854 let _ = fs::remove_dir_all(&dir);
3855 }
3856
3857 #[test]
3858 fn default_project_config_kind_is_toml() {
3859 assert_eq!(super::DEFAULT_PROJECT_CONFIG_KIND, "toml");
3860 assert_eq!(super::DEFAULT_PROJECT_CONFIG_RELATIVE, ".xbp/xbp.toml");
3861 let path = default_project_config_path(std::path::Path::new("/tmp/proj"), "toml");
3862 assert!(path.ends_with(".xbp/xbp.toml") || path.ends_with(r".xbp\xbp.toml"));
3863 assert_eq!(
3864 super::project_config_display_path(std::path::Path::new("/tmp/no-config-here")),
3865 ".xbp/xbp.toml"
3866 );
3867 }
3868
3869 #[test]
3870 fn command_helpers_respect_path_order() {
3871 let bin_dir = make_temp_path("bin");
3872 fs::create_dir_all(&bin_dir).expect("temp dir should be created");
3873 fs::write(bin_dir.join("python"), b"").expect("python file should be created");
3874 fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
3875
3876 with_path(std::slice::from_ref(&bin_dir), || {
3877 assert!(command_exists("python"));
3878 assert_eq!(
3879 first_available_command(&["python3", "python"]),
3880 Some("python".to_string())
3881 );
3882 assert_eq!(preferred_python_command(), "python".to_string());
3883 assert_eq!(preferred_pip_command(), "pip".to_string());
3884 });
3885
3886 fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
3887 }
3888
3889 #[test]
3890 fn resolves_xbp_project_for_nested_paths() {
3891 let project_root = make_temp_path("ownership");
3892 let service_dir = project_root.join("services").join("api");
3893 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3894 fs::create_dir_all(&service_dir).expect("service dir should be created");
3895 fs::write(
3896 project_root.join(".xbp").join("xbp.json"),
3897 br#"{"project_name":"demo"}"#,
3898 )
3899 .expect("xbp config should be written");
3900
3901 let known_projects = vec![KnownXbpProject {
3902 root: project_root.clone(),
3903 name: "demo".to_string(),
3904 }];
3905
3906 assert_eq!(
3907 resolve_xbp_project_for_path(&service_dir, &known_projects),
3908 Some("demo".to_string())
3909 );
3910
3911 fs::remove_dir_all(&project_root).expect("temp project should be removed");
3912 }
3913
3914 #[test]
3915 fn ignores_non_xbp_paths_for_project_resolution() {
3916 let project_root = make_temp_path("ownership-miss");
3917 let other_dir = make_temp_path("ownership-other");
3918 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3919 fs::create_dir_all(&other_dir).expect("other dir should be created");
3920 fs::write(
3921 project_root.join(".xbp").join("xbp.json"),
3922 br#"{"project_name":"demo"}"#,
3923 )
3924 .expect("xbp config should be written");
3925
3926 let known_projects = vec![KnownXbpProject {
3927 root: project_root.clone(),
3928 name: "demo".to_string(),
3929 }];
3930
3931 assert_eq!(
3932 resolve_xbp_project_for_path(&other_dir, &known_projects),
3933 None
3934 );
3935
3936 fs::remove_dir_all(&project_root).expect("temp project should be removed");
3937 fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
3938 }
3939
3940 #[test]
3941 fn auto_converts_legacy_json_to_yaml() {
3942 let project_root = make_temp_path("json-to-yaml");
3944 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3945 let json_path = project_root.join(".xbp").join("xbp.json");
3946 fs::write(
3947 &json_path,
3948 br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
3949 )
3950 .expect("json should be written");
3951
3952 let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
3953 .expect("resolution should succeed")
3954 .expect("existing json path should be returned");
3955 assert_eq!(output, json_path, "should keep the existing JSON path");
3956 assert!(output.exists(), "json config should still exist");
3957 assert!(
3958 !project_root.join(".xbp").join("xbp.yaml").exists(),
3959 "must not force-create YAML from JSON"
3960 );
3961
3962 let json = fs::read_to_string(output).expect("json should be readable");
3963 assert!(json.contains("\"project_name\":\"demo\"") || json.contains("\"project_name\": \"demo\""));
3964
3965 fs::remove_dir_all(project_root).expect("temp project should be removed");
3966 }
3967
3968 #[test]
3969 fn normalizes_environment_quotes_in_yaml_output() {
3970 let yaml = super::normalize_xbp_yaml_environment_quotes(
3971 r#"project_name: demo
3972port: 3000
3973build_dir: ./
3974environment:
3975 API_PATH: /api/auth
3976 WITH_DOUBLE: 'look "here"'
3977 WITH_SINGLE: "can't fail"
3978services:
3979 - name: api
3980 target: rust
3981 branch: main
3982 port: 3001
3983 environment:
3984 CALLBACK_PATH: /api/auth/callback
3985 CALLBACK_TITLE: "the 'quoted' callback"
3986"#,
3987 );
3988
3989 assert!(yaml.contains("API_PATH: \"/api/auth\""));
3990 assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
3991 assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
3992 assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
3993 assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
3994 }
3995
3996 #[test]
3997 fn writes_json_from_yaml_config() {
3998 let project_root = make_temp_path("yaml-to-json");
3999 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
4000 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
4001 let json_out = project_root.join("xbp.json");
4002 fs::write(
4003 &yaml_path,
4004 "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
4005 )
4006 .expect("yaml should be written");
4007
4008 write_json_config_from_any_xbp_config(&yaml_path, &json_out)
4009 .expect("json should be generated from yaml");
4010
4011 let json = fs::read_to_string(json_out).expect("json should be readable");
4012 assert!(json.contains("\"project_name\": \"demo\""));
4013
4014 fs::remove_dir_all(project_root).expect("temp project should be removed");
4015 }
4016
4017 #[test]
4018 fn parses_github_repo_from_supported_remote_urls() {
4019 assert_eq!(
4020 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
4021 Some(("xylex-group".to_string(), "xbp".to_string()))
4022 );
4023 assert_eq!(
4024 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
4025 Some(("xylex-group".to_string(), "xbp".to_string()))
4026 );
4027 assert_eq!(
4028 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
4029 Some(("xylex-group".to_string(), "xbp".to_string()))
4030 );
4031 assert_eq!(
4032 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
4033 None
4034 );
4035 assert_eq!(
4036 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
4037 None
4038 );
4039 }
4040
4041 #[test]
4042 fn redacts_credentials_in_remote_urls() {
4043 let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
4044 assert!(redacted.contains("REDACTED"));
4045 assert!(!redacted.contains("token@example.com"));
4046 }
4047
4048 #[test]
4049 fn reads_origin_remote_url_from_git_config_directory() {
4050 let project_root = make_temp_path("git-config-dir");
4051 let git_dir = project_root.join(".git");
4052 fs::create_dir_all(&git_dir).expect("git dir should be created");
4053 fs::write(
4054 git_dir.join("config"),
4055 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
4056 )
4057 .expect("git config should be written");
4058
4059 let remote = git_remote_url_from_metadata(&project_root, "origin")
4060 .expect("git metadata should parse")
4061 .expect("origin remote should exist");
4062 assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
4063
4064 fs::remove_dir_all(project_root).expect("temp project should be removed");
4065 }
4066
4067 #[test]
4068 fn reads_origin_remote_url_from_gitdir_pointer() {
4069 let project_root = make_temp_path("git-config-file");
4070 let nested_git_dir = project_root.join(".git-real");
4071 fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
4072 fs::write(project_root.join(".git"), "gitdir: .git-real\n")
4073 .expect("gitdir pointer should be written");
4074 fs::write(
4075 nested_git_dir.join("config"),
4076 "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
4077 )
4078 .expect("git config should be written");
4079
4080 let remote = git_remote_url_from_metadata(&project_root, "origin")
4081 .expect("git metadata should parse")
4082 .expect("origin remote should exist");
4083 assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
4084
4085 fs::remove_dir_all(project_root).expect("temp project should be removed");
4086 }
4087
4088 #[test]
4089 fn reads_origin_remote_url_from_ancestor_git_dir() {
4090 let repo_root = make_temp_path("git-ancestor-repo");
4092 let package_root = repo_root.join("packages").join("athena-auth-ui");
4093 fs::create_dir_all(&package_root).expect("package dir should be created");
4094 let git_dir = repo_root.join(".git");
4095 fs::create_dir_all(&git_dir).expect("git dir should be created");
4096 fs::write(
4097 git_dir.join("config"),
4098 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
4099 )
4100 .expect("git config should be written");
4101
4102 let remote = git_remote_url_from_metadata(&package_root, "origin")
4103 .expect("git metadata should parse")
4104 .expect("origin remote should exist via ancestor .git");
4105 assert_eq!(remote, "https://github.com/xylex-group/athena.git");
4106
4107 fs::remove_dir_all(repo_root).expect("temp project should be removed");
4108 }
4109
4110 #[test]
4111 fn durable_object_config_round_trips_yaml_json_jsonc_and_toml() {
4112 let value = serde_json::json!({
4113 "project_name": "worker",
4114 "port": 8787,
4115 "build_dir": ".",
4116 "workers": [{
4117 "name": "auth",
4118 "root": "apps/auth",
4119 "durable_objects": {
4120 "bindings": [{"name": "AUTH", "class_name": "AuthContainer", "script_name": "auth", "environment": "production"}],
4121 "migrations": [{"tag": "v1", "new_sqlite_classes": ["AuthContainer"]}],
4122 "namespaces": [{"id": "namespace-id", "name": "auth_AuthContainer", "script": "auth", "class": "AuthContainer", "use_sqlite": true, "use_containers": true}]
4123 },
4124 "containers": [{"class_name": "AuthContainer", "image": "registry.example/auth:latest", "build_context": ".", "instance_type": "standard", "max_instances": 2, "regions": ["WEUR"]}]
4125 }]
4126 });
4127 for kind in ["yaml", "json", "jsonc", "toml"] {
4128 let rendered = render_xbp_config_value(&value, kind).expect("render config");
4129 let (parsed, _) =
4130 parse_config_with_auto_heal::<Value>(&rendered, kind).expect("parse config");
4131 assert_eq!(
4132 parsed["workers"][0]["durable_objects"]["bindings"][0]["class_name"],
4133 "AuthContainer"
4134 );
4135 assert_eq!(
4136 parsed["workers"][0]["durable_objects"]["namespaces"][0]["use_sqlite"],
4137 true
4138 );
4139 assert_eq!(parsed["workers"][0]["containers"][0]["max_instances"], 2);
4140 }
4141 }
4142}