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