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 first_lookup_value, load_env_lookup, normalize_env_key, normalize_env_value, parse_env_content,
22 parse_env_file, resolve_env_placeholders, strip_utf8_bom, to_env_references,
23 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
24};
25pub use node_toolchain::{
26 find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
27};
28pub use process_monitor_json::{
29 fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
30 CursorProcessMonitorJsonFix,
31};
32pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
33pub use project_paths::{
34 canonicalize_for_subprocess, collapse_project_path, resolve_project_dir, resolve_project_path,
35 resolve_service_root,
36};
37pub use version::{fetch_version, increment_version};
38pub use xbp_ignore::{
39 default_global_worktree_ignore_content, default_xbp_ignore_path,
40 load_global_worktree_ignore_from_path, load_project_xbp_ignore, sync_global_worktree_ignore_file,
41 xbp_ignore_file_candidates, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
42 DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS, DEFAULT_NOISE_DIR_NAMES,
43 GLOBAL_WORKTREE_IGNORE_FILENAME,
44};
45
46use serde::de::DeserializeOwned;
47use serde::{Deserialize, Serialize};
48use serde_json::{Map, Value};
49use std::collections::{BTreeMap, HashMap, HashSet};
50use std::fs;
51use std::path::{Path, PathBuf};
52use std::process::Command;
53use sysinfo::{Pid, System};
54
55use crate::profile::find_all_xbp_projects;
56
57#[derive(Debug, Clone)]
58pub struct FoundXbpConfig {
59 pub project_root: PathBuf,
60 pub config_path: PathBuf,
61 pub kind: &'static str,
62 pub location: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct KnownXbpProject {
67 pub root: PathBuf,
68 pub name: String,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct ListeningPortOwnership {
73 pub pids: Vec<u32>,
74 pub xbp_projects: Vec<String>,
75}
76
77pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
78 project_root.join(".xbp").join("xbp.yaml")
79}
80
81pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
82 let candidates = [
83 project_root.join(".xbp").join("xbp.yaml"),
84 project_root.join(".xbp").join("xbp.yml"),
85 project_root.join("xbp.yaml"),
86 project_root.join("xbp.yml"),
87 ];
88
89 candidates.into_iter().find(|candidate| candidate.exists())
90}
91
92pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
93 project_root: &Path,
94 config_path: &Path,
95) -> Result<Option<PathBuf>, String> {
96 if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
97 return Ok(None);
98 }
99
100 if !config_path.exists() {
101 return Ok(None);
102 }
103
104 if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
105 return Ok(Some(existing_yaml));
106 }
107
108 let content = fs::read_to_string(config_path).map_err(|e| {
109 format!(
110 "Failed to read legacy JSON config {}: {}",
111 config_path.display(),
112 e
113 )
114 })?;
115 let value: Value = serde_json::from_str(&content)
116 .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
117
118 let yaml_path = default_project_yaml_config_path(project_root);
119 if let Some(parent) = yaml_path.parent() {
120 fs::create_dir_all(parent).map_err(|e| {
121 format!(
122 "Failed to create config directory {}: {}",
123 parent.display(),
124 e
125 )
126 })?;
127 }
128
129 let yaml = serialize_xbp_yaml(&value)?;
130 fs::write(&yaml_path, yaml)
131 .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
132
133 Ok(Some(yaml_path))
134}
135
136pub const XBP_PROJECT_YAML_SCHEMA_URL: &str =
138 "https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json";
139
140pub const XBP_PROJECT_YAML_SCHEMA_DIRECTIVE: &str =
142 "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json\n";
143
144pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
145 let yaml = serde_yaml::to_string(value)
146 .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
147 let body = normalize_xbp_yaml_environment_quotes(&yaml);
148 Ok(ensure_xbp_yaml_schema_directive(&body))
149}
150
151pub fn ensure_xbp_yaml_schema_directive(yaml: &str) -> String {
153 let trimmed = yaml.trim_start_matches('\u{feff}');
154 if trimmed
155 .lines()
156 .take(8)
157 .any(|line| line.contains("yaml-language-server") && line.contains("$schema"))
158 {
159 return yaml.to_string();
160 }
161 if let Some(rest) = trimmed.strip_prefix("---") {
163 let rest = rest.strip_prefix('\r').unwrap_or(rest);
164 let rest = rest.strip_prefix('\n').unwrap_or(rest);
165 return format!("---\n{XBP_PROJECT_YAML_SCHEMA_DIRECTIVE}{rest}");
166 }
167 format!("{XBP_PROJECT_YAML_SCHEMA_DIRECTIVE}{trimmed}")
168}
169
170fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
171 let mut rendered = String::with_capacity(yaml.len());
172 let mut environment_indent: Option<usize> = None;
173
174 for raw_line in yaml.split_inclusive('\n') {
175 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
176 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
177 let indent = line
178 .chars()
179 .take_while(|character| *character == ' ')
180 .count();
181
182 if let Some(active_indent) = environment_indent {
183 if !line.trim().is_empty() && indent <= active_indent {
184 environment_indent = None;
185 }
186 }
187
188 if line.trim() == "environment:" {
189 environment_indent = Some(indent);
190 rendered.push_str(line);
191 rendered.push_str(newline);
192 continue;
193 }
194
195 if let Some(active_indent) = environment_indent {
196 if !line.trim().is_empty() && indent > active_indent {
197 if let Some(rewritten) = rewrite_environment_scalar_line(line) {
198 rendered.push_str(&rewritten);
199 rendered.push_str(newline);
200 continue;
201 }
202 }
203 }
204
205 rendered.push_str(line);
206 rendered.push_str(newline);
207 }
208
209 rendered
210}
211
212fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
213 let (prefix, raw_value) = line.split_once(':')?;
214 let value_fragment = raw_value.trim_start();
215 if value_fragment.is_empty() {
216 return Some(format!("{prefix}: \"\""));
217 }
218
219 let parsed =
220 serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
221 let value = match parsed {
222 serde_yaml::Value::Mapping(mut mapping) => {
223 mapping.remove(serde_yaml::Value::String("value".to_string()))?
224 }
225 _ => return None,
226 };
227
228 let text = match value {
229 serde_yaml::Value::String(text) => text,
230 serde_yaml::Value::Bool(value) => value.to_string(),
231 serde_yaml::Value::Number(value) => value.to_string(),
232 serde_yaml::Value::Null => String::new(),
233 _ => return None,
234 };
235
236 Some(format!(
237 "{prefix}: {}",
238 render_yaml_environment_scalar(&text)
239 ))
240}
241
242fn render_yaml_environment_scalar(value: &str) -> String {
243 if value.is_empty() {
244 return "\"\"".to_string();
245 }
246
247 let contains_control = value
248 .chars()
249 .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
250
251 if !contains_control && value.contains('"') {
252 let escaped = value.replace('\'', "''");
253 format!("'{escaped}'")
254 } else {
255 format!("\"{}\"", escape_yaml_double_quoted(value))
256 }
257}
258
259fn escape_yaml_double_quoted(value: &str) -> String {
260 let mut escaped = String::with_capacity(value.len());
261
262 for character in value.chars() {
263 match character {
264 '\\' => escaped.push_str("\\\\"),
265 '"' => escaped.push_str("\\\""),
266 '\n' => escaped.push_str("\\n"),
267 '\r' => escaped.push_str("\\r"),
268 '\t' => escaped.push_str("\\t"),
269 character if character.is_control() => {
270 use std::fmt::Write as _;
271 let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
272 }
273 character => escaped.push(character),
274 }
275 }
276
277 escaped
278}
279
280pub fn write_json_config_from_any_xbp_config(
281 config_path: &Path,
282 output_json_path: &Path,
283) -> Result<(), String> {
284 let content = fs::read_to_string(config_path)
285 .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
286
287 let kind = config_kind_from_path(config_path)?;
288 let (value, _) = parse_config_with_auto_heal::<Value>(&content, kind)
289 .map_err(|e| format!("Failed to parse config: {}", e))?;
290
291 if let Some(parent) = output_json_path.parent() {
292 fs::create_dir_all(parent).map_err(|e| {
293 format!(
294 "Failed to create config directory {}: {}",
295 parent.display(),
296 e
297 )
298 })?;
299 }
300
301 let rendered = serde_json::to_string_pretty(&value)
302 .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
303 fs::write(output_json_path, rendered).map_err(|e| {
304 format!(
305 "Failed to write JSON config {}: {}",
306 output_json_path.display(),
307 e
308 )
309 })?;
310
311 Ok(())
312}
313
314pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
315 for dir in start_dir.ancestors() {
316 let candidates: [(PathBuf, &'static str); 10] = [
317 (dir.join(".xbp").join("xbp.toml"), "toml"),
318 (dir.join(".xbp").join("xbp.jsonc"), "jsonc"),
319 (dir.join(".xbp").join("xbp.yaml"), "yaml"),
320 (dir.join(".xbp").join("xbp.yml"), "yaml"),
321 (dir.join(".xbp").join("xbp.json"), "json"),
322 (dir.join("xbp.toml"), "toml"),
323 (dir.join("xbp.jsonc"), "jsonc"),
324 (dir.join("xbp.yaml"), "yaml"),
325 (dir.join("xbp.yml"), "yaml"),
326 (dir.join("xbp.json"), "json"),
327 ];
328
329 for (path, kind) in candidates {
330 if !path.exists() {
331 continue;
332 }
333
334 let project_root = path
335 .parent()
336 .and_then(|p| {
337 if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
338 p.parent().map(|pp| pp.to_path_buf())
339 } else {
340 Some(p.to_path_buf())
341 }
342 })
343 .unwrap_or_else(|| dir.to_path_buf());
344
345 let location = path
346 .strip_prefix(&project_root)
347 .ok()
348 .map(|p| p.to_string_lossy().replace('\\', "/"))
349 .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
350
351 return Some(FoundXbpConfig {
352 project_root,
353 config_path: path,
354 kind,
355 location,
356 });
357 }
358 }
359
360 None
361}
362
363pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
364 let mut projects = Vec::new();
365 let mut seen_roots = HashSet::new();
366
367 for project in find_all_xbp_projects() {
368 let root = canonicalize_or_fallback(&project.path);
369 if seen_roots.insert(root.clone()) {
370 projects.push(KnownXbpProject {
371 root,
372 name: project.name,
373 });
374 }
375 }
376
377 if let Ok(current_dir) = std::env::current_dir() {
378 if let Some(found) = find_xbp_config_upwards(¤t_dir) {
379 let root = canonicalize_or_fallback(&found.project_root);
380 if seen_roots.insert(root.clone()) {
381 let name = root
382 .file_name()
383 .and_then(|value| value.to_str())
384 .unwrap_or("current")
385 .to_string();
386 projects.push(KnownXbpProject { root, name });
387 }
388 }
389 }
390
391 projects.sort_by(|left, right| {
392 right
393 .root
394 .components()
395 .count()
396 .cmp(&left.root.components().count())
397 .then_with(|| left.name.cmp(&right.name))
398 });
399 projects
400}
401
402pub fn resolve_xbp_project_for_path(
403 candidate: &Path,
404 known_projects: &[KnownXbpProject],
405) -> Option<String> {
406 if candidate.as_os_str().is_empty() {
407 return None;
408 }
409
410 if let Some(found) = find_xbp_config_upwards(candidate) {
411 let found_root = canonicalize_or_fallback(&found.project_root);
412 if let Some(project) = known_projects
413 .iter()
414 .find(|project| canonicalize_or_fallback(&project.root) == found_root)
415 {
416 return Some(project.name.clone());
417 }
418
419 if known_projects.is_empty() {
420 return found_root
421 .file_name()
422 .and_then(|value| value.to_str())
423 .map(|value| value.to_string());
424 }
425 }
426
427 let candidate_path = canonicalize_or_fallback(candidate);
428 known_projects
429 .iter()
430 .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
431 .map(|project| project.name.clone())
432}
433
434pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
435 use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
436
437 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
438 let proto_flags = ProtocolFlags::TCP;
439 let sockets = get_sockets_info(af_flags, proto_flags)
440 .map_err(|e| format!("Failed to get sockets info: {}", e))?;
441
442 let mut system = System::new_all();
443 system.refresh_all();
444 let known_projects = collect_known_xbp_projects();
445 let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
446 let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
447
448 for socket in sockets {
449 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
450 let state = format!("{:?}", tcp.state);
451 if state != "Listen" && state != "LISTEN" {
452 continue;
453 }
454
455 let row = ports.entry(tcp.local_port).or_default();
456 for pid in socket.associated_pids {
457 row.pids.push(pid);
458 if let Some(project) = pid_project_cache
459 .entry(pid)
460 .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
461 .clone()
462 {
463 row.xbp_projects.push(project);
464 }
465 }
466 }
467 }
468
469 for row in ports.values_mut() {
470 row.pids.sort_unstable();
471 row.pids.dedup();
472 row.xbp_projects.sort();
473 row.xbp_projects.dedup();
474 }
475
476 Ok(ports)
477}
478
479fn resolve_xbp_project_for_pid(
480 pid: u32,
481 system: &System,
482 known_projects: &[KnownXbpProject],
483) -> Option<String> {
484 let process = system.process(Pid::from_u32(pid))?;
485
486 if let Some(project) = process
487 .cwd()
488 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
489 {
490 return Some(project);
491 }
492
493 process
494 .exe()
495 .and_then(|path| path.parent())
496 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
497}
498
499fn canonicalize_or_fallback(path: &Path) -> PathBuf {
500 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
501}
502
503pub fn expand_home_in_string(input: &str) -> String {
504 let home = dirs::home_dir()
505 .unwrap_or_else(|| std::path::PathBuf::from("."))
506 .to_string_lossy()
507 .to_string();
508
509 if input == "~" {
510 return home;
511 }
512
513 if let Some(rest) = input
514 .strip_prefix("~/")
515 .or_else(|| input.strip_prefix("~\\"))
516 {
517 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
518 }
519
520 if let Some(rest) = input
521 .strip_prefix("$HOME/")
522 .or_else(|| input.strip_prefix("$HOME\\"))
523 {
524 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
525 }
526
527 if let Some(rest) = input
528 .strip_prefix("${HOME}/")
529 .or_else(|| input.strip_prefix("${HOME}\\"))
530 {
531 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
532 }
533
534 input.to_string()
535}
536
537pub fn collapse_home_to_env(input: &str) -> String {
538 let home = dirs::home_dir()
539 .unwrap_or_else(|| std::path::PathBuf::from("."))
540 .to_string_lossy()
541 .to_string();
542
543 if input == home {
544 return "$HOME".to_string();
545 }
546
547 if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
548 return format!("$HOME/{}", rest);
549 }
550
551 if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
552 return format!("$HOME\\{}", rest);
553 }
554
555 input.to_string()
556}
557
558pub fn cargo_program() -> PathBuf {
559 std::env::var_os("CARGO")
560 .map(PathBuf::from)
561 .unwrap_or_else(|| PathBuf::from("cargo"))
562}
563
564pub fn cargo_command_exists() -> bool {
565 let program = cargo_program();
566 if program.is_absolute() {
567 return program.is_file();
568 }
569 command_exists(program.to_string_lossy().as_ref())
570}
571
572pub fn command_exists(program: &str) -> bool {
573 let Some(path_var) = std::env::var_os("PATH") else {
574 return false;
575 };
576
577 for dir in std::env::split_paths(&path_var) {
578 let candidate = dir.join(program);
579 if candidate.is_file() {
580 return true;
581 }
582
583 #[cfg(windows)]
584 for ext in ["exe", "cmd", "bat"] {
585 let candidate = dir.join(format!("{}.{}", program, ext));
586 if candidate.is_file() {
587 return true;
588 }
589 }
590 }
591
592 false
593}
594
595pub fn git_remote_url_from_metadata(
596 project_root: &Path,
597 remote: &str,
598) -> Result<Option<String>, String> {
599 let Some(git_dir) = resolve_git_dir(project_root)? else {
600 return Ok(None);
601 };
602
603 let config_path = git_dir.join("config");
604 if !config_path.exists() {
605 return Ok(None);
606 }
607
608 let content = fs::read_to_string(&config_path)
609 .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
610
611 Ok(parse_git_remote_url_from_config(&content, remote))
612}
613
614pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
615 let normalized: &str = url.trim();
616
617 let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
618 path.to_string()
619 } else if let Some(path) = parse_github_https_repo_path(normalized) {
620 path
621 } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
622 path.to_string()
623 } else {
624 return None;
625 };
626
627 let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
628 let mut segments: std::str::Split<'_, char> = cleaned.split('/');
629 let owner: &str = segments.next()?.trim();
630 let repo: &str = segments.next()?.trim();
631 if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
632 return None;
633 }
634
635 Some((owner.to_string(), repo.to_string()))
636}
637
638pub fn redact_remote_url_credentials(url: &str) -> String {
639 if !url.contains('@') || !url.contains("://") {
640 return url.to_string();
641 }
642 let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
643 Ok(value) => value,
644 Err(_) => return url.to_string(),
645 };
646 if parsed.password().is_some() {
647 let _ = parsed.set_password(Some("REDACTED"));
648 }
649 if !parsed.username().is_empty() {
650 let _ = parsed.set_username("REDACTED");
651 }
652 parsed.to_string()
653}
654
655fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
656 for dir in project_root.ancestors() {
659 if let Some(git_dir) = resolve_git_dir_at(dir)? {
660 return Ok(Some(git_dir));
661 }
662 }
663 Ok(None)
664}
665
666fn resolve_git_dir_at(dir: &Path) -> Result<Option<PathBuf>, String> {
667 let dot_git = dir.join(".git");
668 if dot_git.is_dir() {
669 return Ok(Some(dot_git));
670 }
671
672 if !dot_git.exists() {
673 return Ok(None);
674 }
675
676 let content = fs::read_to_string(&dot_git)
677 .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
678 let git_dir = content
679 .lines()
680 .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
681 .filter(|value| !value.is_empty())
682 .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
683
684 let git_dir_path = PathBuf::from(git_dir);
685 let resolved = if git_dir_path.is_absolute() {
686 git_dir_path
687 } else {
688 dot_git.parent().unwrap_or(dir).join(git_dir_path)
689 };
690
691 Ok(Some(resolved))
692}
693
694fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
695 let expected_quoted = format!(r#"remote "{}""#, remote);
696 let expected_dotted = format!("remote.{}", remote);
697 let mut in_target_section = false;
698
699 for line in content.lines() {
700 let trimmed = line.trim();
701 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
702 continue;
703 }
704
705 if trimmed.starts_with('[') && trimmed.ends_with(']') {
706 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
707 in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
708 || section.eq_ignore_ascii_case(&expected_dotted);
709 continue;
710 }
711
712 if !in_target_section {
713 continue;
714 }
715
716 let Some((key, value)) = trimmed.split_once('=') else {
717 continue;
718 };
719 if key.trim().eq_ignore_ascii_case("url") {
720 let value = value.trim();
721 if !value.is_empty() {
722 return Some(value.to_string());
723 }
724 }
725 }
726
727 None
728}
729
730fn parse_github_https_repo_path(url: &str) -> Option<String> {
731 let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
732 if !matches!(parsed.scheme(), "http" | "https") {
733 return None;
734 }
735 if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
736 return Some(parsed.path().trim_start_matches('/').to_string());
737 }
738 None
739}
740
741pub fn first_available_command(candidates: &[&str]) -> Option<String> {
742 candidates
743 .iter()
744 .find(|candidate| command_exists(candidate))
745 .map(|candidate| (*candidate).to_string())
746}
747
748pub fn preferred_python_command() -> String {
749 first_available_command(&["python3", "python"]).unwrap_or_else(|| {
750 if cfg!(target_os = "windows") {
751 "python".to_string()
752 } else {
753 "python3".to_string()
754 }
755 })
756}
757
758pub fn preferred_pip_command() -> String {
759 first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
760 if cfg!(target_os = "windows") {
761 "pip".to_string()
762 } else {
763 "pip3".to_string()
764 }
765 })
766}
767
768pub fn open_with_default_handler(target: &str) -> Result<(), String> {
769 let mut command = if cfg!(target_os = "windows") {
770 let mut cmd = Command::new("cmd");
771 cmd.arg("/C").arg("start").arg("").arg(target);
772 cmd
773 } else if cfg!(target_os = "macos") {
774 let mut cmd = Command::new("open");
775 cmd.arg(target);
776 cmd
777 } else {
778 let mut cmd = Command::new("xdg-open");
779 cmd.arg(target);
780 cmd
781 };
782
783 command
784 .spawn()
785 .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
786 Ok(())
787}
788
789pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
790 if let Ok(editor) = std::env::var("EDITOR") {
791 let mut parts = editor.split_whitespace();
792 let binary = parts
793 .next()
794 .ok_or_else(|| "EDITOR is set but empty".to_string())?;
795 let mut command = Command::new(binary);
796 for part in parts {
797 command.arg(part);
798 }
799 command
800 .arg(path)
801 .spawn()
802 .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
803 return Ok(());
804 }
805
806 open_with_default_handler(&path.display().to_string())
807}
808
809#[derive(Debug, Clone, Default, PartialEq, Eq)]
811struct ConfigParseNormalizeMeta {
812 had_bom: bool,
814 multi_document: bool,
816 duplicate_top_level_keys: bool,
818}
819
820impl ConfigParseNormalizeMeta {
821 fn needs_rewrite(&self) -> bool {
822 self.had_bom || self.multi_document || self.duplicate_top_level_keys
823 }
824}
825
826fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
833 let mut output = String::with_capacity(content.len());
834 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
835 let mut skipping_duplicate_block = false;
836 let mut changed = false;
837
838 for raw_line in content.split_inclusive('\n') {
839 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
840 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
841 let trimmed = line.trim_start();
842 let indent = line.len().saturating_sub(trimmed.len());
843
844 if trimmed == "---" || trimmed.starts_with("--- ") {
846 skipping_duplicate_block = false;
847 seen.clear();
848 output.push_str(line);
849 output.push_str(newline);
850 continue;
851 }
852
853 if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
854 if let Some(key) = top_level_yaml_key(trimmed) {
856 if !seen.insert(key) {
857 skipping_duplicate_block = true;
859 changed = true;
860 continue;
861 }
862 skipping_duplicate_block = false;
863 } else {
864 skipping_duplicate_block = false;
866 }
867 } else if skipping_duplicate_block {
868 if indent > 0 || trimmed.is_empty() || trimmed.starts_with('#') {
870 changed = true;
871 continue;
872 }
873 skipping_duplicate_block = false;
874 }
875
876 if skipping_duplicate_block {
877 changed = true;
878 continue;
879 }
880
881 output.push_str(line);
882 output.push_str(newline);
883 }
884
885 (output, changed)
886}
887
888fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
889 if trimmed_line.starts_with('-') {
890 return None;
891 }
892 let without_comment = trimmed_line
893 .split_once(" #")
894 .map(|(left, _)| left)
895 .unwrap_or(trimmed_line)
896 .trim_end();
897 let (key, rest) = without_comment.split_once(':')?;
898 let key = key.trim();
899 if key.is_empty() || key.contains(' ') || key.contains('\t') {
900 return None;
901 }
902 if rest.starts_with("//") {
904 return None;
905 }
906 Some(key.to_string())
907}
908
909fn parse_yaml_config_documents(
918 content: &str,
919) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
920 let had_bom = content.starts_with('\u{feff}');
921 let stripped = strip_utf8_bom(content);
922 let (deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys(stripped);
923 let stripped = deduped.as_str();
924
925 let mut documents = Vec::new();
926 for document in serde_yaml::Deserializer::from_str(stripped) {
927 let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
928 if !matches!(value, serde_yaml::Value::Null) {
930 documents.push(value);
931 }
932 }
933
934 if documents.is_empty() {
935 return Ok((
936 serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
937 ConfigParseNormalizeMeta {
938 had_bom,
939 multi_document: false,
940 duplicate_top_level_keys,
941 },
942 ));
943 }
944
945 let multi_document = documents.len() > 1;
946 let merged = if multi_document {
947 merge_yaml_values(documents)
948 } else {
949 documents
950 .into_iter()
951 .next()
952 .unwrap_or(serde_yaml::Value::Null)
953 };
954
955 Ok((
956 merged,
957 ConfigParseNormalizeMeta {
958 had_bom,
959 multi_document,
960 duplicate_top_level_keys,
961 },
962 ))
963}
964
965fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
966 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
967 for document in documents {
968 deep_merge_yaml(&mut merged, document);
969 }
970 merged
971}
972
973fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
976 match (base, incoming) {
977 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
978 for (key, value) in incoming_map {
979 match base_map.get_mut(&key) {
980 Some(existing) => deep_merge_yaml(existing, value),
981 None => {
982 base_map.insert(key, value);
983 }
984 }
985 }
986 }
987 (base_slot, incoming) => {
988 *base_slot = incoming;
989 }
990 }
991}
992
993pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
994 content: &str,
995 kind: &str,
996) -> Result<(T, Option<String>), String> {
997 let (mut value, structural_heal) = match kind {
998 "yaml" => {
999 let (yaml_value, meta) = parse_yaml_config_documents(content)?;
1000 let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
1001 (json, meta.needs_rewrite())
1002 }
1003 "json" => {
1004 let had_bom = content.starts_with('\u{feff}');
1005 let stripped = strip_utf8_bom(content);
1006 let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
1007 (json, had_bom)
1008 }
1009 "jsonc" => {
1010 let stripped = strip_jsonc_comments_light(strip_utf8_bom(content));
1011 let json = serde_json::from_str::<Value>(&stripped).map_err(|e| e.to_string())?;
1012 (json, stripped != content)
1013 }
1014 "toml" => {
1015 let value = toml::from_str::<toml::Value>(strip_utf8_bom(content))
1016 .map_err(|e| e.to_string())?;
1017 let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
1018 (json, false)
1019 }
1020 _ => return Err(format!("Unsupported config kind: {}", kind)),
1021 };
1022
1023 let field_healed = auto_heal_xbp_config_value(&mut value);
1024 let healed = field_healed || structural_heal;
1025 let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
1026
1027 let healed_content = if healed {
1028 Some(match kind {
1029 "yaml" => serialize_xbp_yaml(&value)?,
1030 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
1031 "jsonc" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
1032 "toml" => render_xbp_config_value(&value, "toml")?,
1033 _ => unreachable!(),
1034 })
1035 } else {
1036 None
1037 };
1038
1039 Ok((parsed, healed_content))
1040}
1041
1042pub fn config_kind_from_path(path: &Path) -> Result<&'static str, String> {
1043 match path
1044 .extension()
1045 .and_then(|ext| ext.to_str())
1046 .unwrap_or_default()
1047 .to_ascii_lowercase()
1048 .as_str()
1049 {
1050 "yaml" | "yml" => Ok("yaml"),
1051 "json" => Ok("json"),
1052 "jsonc" => Ok("jsonc"),
1053 "toml" => Ok("toml"),
1054 other => Err(format!("Unsupported XBP config extension `{other}`.")),
1055 }
1056}
1057
1058pub fn render_xbp_config_value(value: &Value, kind: &str) -> Result<String, String> {
1059 match kind {
1060 "yaml" => serialize_xbp_yaml(value),
1061 "json" => serde_json::to_string_pretty(value).map_err(|e| e.to_string()),
1062 "jsonc" => serde_json::to_string_pretty(value).map_err(|e| e.to_string()),
1063 "toml" => {
1064 let toml_value = json_value_to_toml(value)?;
1065 toml::to_string_pretty(&toml_value).map_err(|e| e.to_string())
1066 }
1067 _ => Err(format!("Unsupported XBP config kind `{kind}`.")),
1068 }
1069}
1070
1071fn json_value_to_toml(value: &Value) -> Result<toml::Value, String> {
1072 match value {
1073 Value::Object(entries) => {
1074 let mut table = toml::map::Map::new();
1075 for (key, value) in entries {
1076 if value.is_null() {
1077 continue;
1078 }
1079 table.insert(key.clone(), json_value_to_toml(value)?);
1080 }
1081 Ok(toml::Value::Table(table))
1082 }
1083 Value::Array(values) => Ok(toml::Value::Array(
1084 values
1085 .iter()
1086 .map(json_value_to_toml)
1087 .collect::<Result<_, _>>()?,
1088 )),
1089 Value::String(value) => Ok(toml::Value::String(value.clone())),
1090 Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
1091 Value::Number(value) => value
1092 .as_i64()
1093 .map(toml::Value::Integer)
1094 .or_else(|| {
1095 value
1096 .as_u64()
1097 .and_then(|number| i64::try_from(number).ok())
1098 .map(toml::Value::Integer)
1099 })
1100 .or_else(|| value.as_f64().map(toml::Value::Float))
1101 .ok_or_else(|| "Unsupported JSON number in TOML conversion.".to_string()),
1102 Value::Null => Err("Null cannot be represented in TOML.".to_string()),
1103 }
1104}
1105
1106pub fn strip_jsonc_comments_light(input: &str) -> String {
1110 let mut output = String::with_capacity(input.len());
1111 let bytes = input.as_bytes();
1112 let mut index = 0usize;
1113 let mut in_string = false;
1114 let mut escaped = false;
1115 while index < bytes.len() {
1116 let character = bytes[index] as char;
1117 if in_string {
1118 output.push(character);
1119 if escaped {
1120 escaped = false;
1121 } else if character == '\\' {
1122 escaped = true;
1123 } else if character == '"' {
1124 in_string = false;
1125 }
1126 index += 1;
1127 continue;
1128 }
1129 if character == '"' {
1130 in_string = true;
1131 output.push(character);
1132 index += 1;
1133 continue;
1134 }
1135 if character == '/' && index + 1 < bytes.len() {
1136 match bytes[index + 1] as char {
1137 '/' => {
1138 index += 2;
1139 while index < bytes.len() && bytes[index] != b'\n' {
1140 index += 1;
1141 }
1142 continue;
1143 }
1144 '*' => {
1145 index += 2;
1146 while index + 1 < bytes.len()
1147 && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
1148 {
1149 index += 1;
1150 }
1151 index = (index + 2).min(bytes.len());
1152 continue;
1153 }
1154 _ => {}
1155 }
1156 }
1157 if character == ',' {
1158 let mut lookahead = index + 1;
1159 while lookahead < bytes.len() && (bytes[lookahead] as char).is_whitespace() {
1160 lookahead += 1;
1161 }
1162 if lookahead < bytes.len() && matches!(bytes[lookahead] as char, '}' | ']') {
1163 index += 1;
1164 continue;
1165 }
1166 }
1167 output.push(character);
1168 index += 1;
1169 }
1170 output
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1174pub struct XbpConfigHealResult {
1175 pub config_path: PathBuf,
1176 pub fixes: Vec<String>,
1177}
1178
1179pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
1180 let mut fixes = Vec::new();
1181
1182 if content.starts_with('\u{feff}') {
1183 fixes.push("strip leading UTF-8 BOM".to_string());
1184 }
1185
1186 let stripped = strip_utf8_bom(content);
1187 if content_looks_like_yaml_multidoc(stripped) {
1189 fixes.push("collapse multi-document YAML into a single document".to_string());
1190 }
1191
1192 for line in content.lines() {
1193 let trimmed = line.trim();
1194 if !trimmed.starts_with("systemd:") {
1195 continue;
1196 }
1197 let value = trimmed.trim_start_matches("systemd:").trim();
1198 if value.is_empty() || value == "null" || value.starts_with('{') {
1199 continue;
1200 }
1201 let normalized = value.trim_matches('"').trim_matches('\'');
1202 fixes.push(format!(
1203 "normalize `systemd: {normalized}` to `systemd_service_name`"
1204 ));
1205 }
1206
1207 fixes.sort();
1208 fixes.dedup();
1209 fixes
1210}
1211
1212fn content_looks_like_yaml_multidoc(content: &str) -> bool {
1213 let mut document_count = 0usize;
1214 for document in serde_yaml::Deserializer::from_str(content) {
1215 match serde_yaml::Value::deserialize(document) {
1216 Ok(serde_yaml::Value::Null) => {}
1217 Ok(_) => {
1218 document_count += 1;
1219 if document_count > 1 {
1220 return true;
1221 }
1222 }
1223 Err(_) => return false,
1224 }
1225 }
1226 false
1227}
1228
1229pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
1230 let found = match find_xbp_config_upwards(start_dir) {
1231 Some(found) => found,
1232 None => return Ok(None),
1233 };
1234
1235 let content = fs::read_to_string(&found.config_path)
1236 .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
1237 let fixes = detect_xbp_config_heal_opportunities(&content);
1238 let healed = heal_config_file(&found.config_path, found.kind)?;
1239
1240 if !healed && fixes.is_empty() {
1241 return Ok(None);
1242 }
1243
1244 Ok(Some(XbpConfigHealResult {
1245 config_path: found.config_path,
1246 fixes: if fixes.is_empty() {
1247 vec!["normalized legacy config fields".to_string()]
1248 } else {
1249 fixes
1250 },
1251 }))
1252}
1253
1254pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
1255 let content = fs::read_to_string(path)
1256 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1257 let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
1258
1259 if let Some(healed_content) = healed_content {
1260 fs::write(path, healed_content)
1261 .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
1262 return Ok(true);
1263 }
1264
1265 Ok(false)
1266}
1267
1268pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
1269 auto_heal_xbp_config_value(value)
1270}
1271
1272fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
1273 let Some(root) = value.as_object_mut() else {
1274 return false;
1275 };
1276
1277 let mut changed = false;
1278
1279 if let Some(environment) = root.get_mut("environment") {
1280 changed |= normalize_environment_value(environment);
1281 }
1282
1283 changed |= normalize_systemd_shorthand(root);
1284
1285 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
1286 for service in services {
1287 let Some(service) = service.as_object_mut() else {
1288 continue;
1289 };
1290
1291 if let Some(environment) = service.get_mut("environment") {
1292 changed |= normalize_environment_value(environment);
1293 }
1294
1295 changed |= normalize_systemd_shorthand(service);
1296 }
1297 }
1298
1299 changed
1300}
1301
1302fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
1303 let Some(systemd) = map.get("systemd") else {
1304 return false;
1305 };
1306
1307 let Value::String(service_name) = systemd else {
1308 return false;
1309 };
1310
1311 let systemd_service_name_missing = map
1312 .get("systemd_service_name")
1313 .map(|value| value.is_null())
1314 .unwrap_or(true);
1315
1316 if systemd_service_name_missing && !service_name.is_empty() {
1317 map.insert(
1318 "systemd_service_name".to_string(),
1319 Value::String(service_name.clone()),
1320 );
1321 }
1322
1323 map.remove("systemd");
1324 true
1325}
1326
1327fn normalize_environment_value(value: &mut Value) -> bool {
1328 let Value::Object(map) = value else {
1329 return false;
1330 };
1331
1332 let original = map.clone();
1333 let mut normalized = Map::new();
1334 let mut changed = false;
1335
1336 flatten_environment_entries(&original, &mut normalized, &mut changed);
1337
1338 if normalized != original {
1339 *map = normalized;
1340 changed = true;
1341 }
1342
1343 changed
1344}
1345
1346fn flatten_environment_entries(
1347 source: &Map<String, Value>,
1348 target: &mut Map<String, Value>,
1349 changed: &mut bool,
1350) {
1351 for (key, value) in source {
1352 match value {
1353 Value::String(string) => {
1354 target.insert(key.clone(), Value::String(string.clone()));
1355 }
1356 Value::Number(number) => {
1357 *changed = true;
1358 target.insert(key.clone(), Value::String(number.to_string()));
1359 }
1360 Value::Bool(boolean) => {
1361 *changed = true;
1362 target.insert(key.clone(), Value::String(boolean.to_string()));
1363 }
1364 Value::Null => {
1365 *changed = true;
1366 target.insert(key.clone(), Value::String(String::new()));
1367 }
1368 Value::Array(items) => {
1369 *changed = true;
1370 let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
1371 target.insert(key.clone(), Value::String(serialized));
1372 }
1373 Value::Object(nested) => {
1374 *changed = true;
1375 flatten_environment_entries(nested, target, changed);
1376 }
1377 }
1378 }
1379}
1380
1381#[cfg(test)]
1382mod tests {
1383 use super::{
1384 command_exists, detect_xbp_config_heal_opportunities, first_available_command,
1385 git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
1386 parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
1387 preferred_python_command, redact_remote_url_credentials, render_xbp_config_value,
1388 resolve_xbp_project_for_path, write_json_config_from_any_xbp_config, KnownXbpProject,
1389 };
1390 use crate::strategies::XbpConfig;
1391 use serde_json::Value;
1392 use std::fs;
1393 use std::path::PathBuf;
1394 use std::sync::{Mutex, OnceLock};
1395
1396 fn path_lock() -> &'static Mutex<()> {
1397 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1398 LOCK.get_or_init(|| Mutex::new(()))
1399 }
1400
1401 fn make_temp_path(name: &str) -> PathBuf {
1402 let mut path = std::env::temp_dir();
1403 path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1404 path
1405 }
1406
1407 fn with_path<F>(entries: &[PathBuf], test: F)
1408 where
1409 F: FnOnce(),
1410 {
1411 let _guard = path_lock().lock().expect("path lock should be available");
1412 let original = std::env::var_os("PATH");
1413 let joined = std::env::join_paths(entries).expect("PATH entries should join");
1414 std::env::set_var("PATH", joined);
1415 test();
1416 match original {
1417 Some(path) => std::env::set_var("PATH", path),
1418 None => std::env::remove_var("PATH"),
1419 }
1420 }
1421
1422 #[test]
1423 fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
1424 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
1426
1427 let (config, healed_content) =
1428 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
1429
1430 assert_eq!(config.project_name, "demo");
1431 assert_eq!(config.port, 3000);
1432 let healed = healed_content.expect("BOM should trigger autonomous rewrite");
1433 assert!(
1434 !healed.starts_with('\u{feff}'),
1435 "healed yaml must not retain BOM"
1436 );
1437 assert!(healed.contains("project_name: demo"));
1438 }
1439
1440 #[test]
1441 fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
1442 let yaml = r#"
1443project_name: athena
1444version: 1.0.0
1445port: 4052
1446build_dir: ./
1447openapi:
1448 enabled: true
1449 auto_detect_services: false
1450services:
1451 - name: athena
1452 target: rust
1453 branch: main
1454 port: 4052
1455 openapi: null
1456openapi: null
1457workers:
1458 - name: athena-auth
1459 root: services/athena-auth
1460"#;
1461
1462 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1463 .expect("duplicate openapi key should be healed");
1464 assert_eq!(config.project_name, "athena");
1465 assert_eq!(
1466 config.openapi.as_ref().and_then(|o| o.enabled),
1467 Some(true)
1468 );
1469 assert!(config.workers.is_some());
1470 let healed = healed_content.expect("duplicate key should trigger rewrite");
1471 let top_level_openapi_lines = healed
1472 .lines()
1473 .filter(|line| line.starts_with("openapi:"))
1474 .count();
1475 assert_eq!(
1476 top_level_openapi_lines, 1,
1477 "healed yaml should keep a single top-level openapi block: {healed}"
1478 );
1479 assert!(
1480 !healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
1481 "stray top-level openapi: null must be dropped: {healed}"
1482 );
1483 }
1484
1485 #[test]
1486 fn merges_multi_document_yaml_and_rewrites_single_document() {
1487 let yaml = r#"
1488project_name: demo
1489port: 3000
1490build_dir: $HOME/demo
1491---
1492environment:
1493 LOG_LEVEL: info
1494---
1495services:
1496 - name: api
1497 target: rust
1498 branch: main
1499 port: 3001
1500"#;
1501
1502 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1503 .expect("multi-document yaml should merge");
1504
1505 assert_eq!(config.project_name, "demo");
1506 assert_eq!(config.port, 3000);
1507 let environment = config.environment.expect("merged environment should exist");
1508 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1509 let services = config.services.expect("merged services should exist");
1510 assert_eq!(services.len(), 1);
1511 assert_eq!(services[0].name, "api");
1512
1513 let healed = healed_content.expect("multi-doc should trigger rewrite");
1514 assert!(
1515 !healed.lines().any(|line| line.trim() == "---"),
1516 "healed yaml should be a single document"
1517 );
1518 assert!(healed.contains("project_name: demo"));
1519 assert!(healed.contains("LOG_LEVEL"));
1520 assert!(healed.contains("name: api"));
1521 }
1522
1523 #[test]
1524 fn parses_bom_prefixed_multi_document_yaml() {
1525 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
1526
1527 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1528 .expect("BOM + multi-doc yaml should parse");
1529
1530 assert_eq!(config.project_name, "demo");
1531 assert_eq!(config.version, "1.2.3");
1532 assert!(healed_content.is_some());
1533 }
1534
1535 #[test]
1536 fn ensure_xbp_yaml_schema_directive_is_idempotent() {
1537 let bare = "project_name: demo\nport: 3000\nbuild_dir: .\n";
1538 let once = super::ensure_xbp_yaml_schema_directive(bare);
1539 assert!(once.starts_with("# yaml-language-server: $schema="));
1540 assert!(once.contains("project_name: demo"));
1541 let twice = super::ensure_xbp_yaml_schema_directive(&once);
1542 assert_eq!(
1543 once.matches("yaml-language-server").count(),
1544 1,
1545 "should not duplicate schema directive"
1546 );
1547 assert_eq!(twice, once);
1548
1549 let with_doc = "---\nproject_name: demo\n";
1550 let headed = super::ensure_xbp_yaml_schema_directive(with_doc);
1551 assert!(headed.starts_with("---\n# yaml-language-server:"));
1552 }
1553
1554 #[test]
1555 fn detect_heal_opportunities_reports_bom_and_multidoc() {
1556 let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
1557 let fixes = detect_xbp_config_heal_opportunities(yaml);
1558 assert!(
1559 fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
1560 "expected BOM fix, got {fixes:?}"
1561 );
1562 assert!(
1563 fixes.iter().any(|fix| fix.contains("multi-document")),
1564 "expected multi-doc fix, got {fixes:?}"
1565 );
1566 assert!(
1567 fixes.iter().any(|fix| fix.contains("systemd")),
1568 "expected systemd fix, got {fixes:?}"
1569 );
1570 }
1571
1572 #[test]
1573 fn heal_config_file_strips_bom_on_disk() {
1574 let project_root = make_temp_path("heal-bom-yaml");
1575 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1576 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1577 fs::write(
1578 &yaml_path,
1579 "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1580 )
1581 .expect("yaml should be written with BOM");
1582
1583 let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
1584 assert!(healed, "BOM config should be rewritten");
1585
1586 let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
1587 assert!(
1588 !rewritten.starts_with('\u{feff}'),
1589 "rewritten file must not start with BOM"
1590 );
1591 assert!(rewritten.contains("project_name: demo"));
1592
1593 let (config, second_heal) =
1595 parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
1596 assert_eq!(config.project_name, "demo");
1597 assert!(second_heal.is_none());
1600
1601 fs::remove_dir_all(project_root).expect("temp project should be removed");
1602 }
1603
1604 #[test]
1605 fn heals_nested_yaml_environment_blocks() {
1606 let yaml = r#"
1607project_name: demo
1608port: 3000
1609build_dir: $HOME/demo
1610environment:
1611 production:
1612 DATABASE_URL: postgres://localhost/demo
1613 LOG_LEVEL: info
1614services:
1615 - name: api
1616 target: rust
1617 branch: main
1618 port: 3001
1619 environment:
1620 production:
1621 SERVICE_TOKEN: abc123
1622"#;
1623
1624 let (config, healed_content) =
1625 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1626
1627 let environment = config.environment.expect("top-level env should exist");
1628 assert_eq!(
1629 environment.get("DATABASE_URL"),
1630 Some(&"postgres://localhost/demo".to_string())
1631 );
1632 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1633
1634 let service_environment = config.services.expect("services should exist")[0]
1635 .environment
1636 .clone()
1637 .expect("service env should exist");
1638 assert_eq!(
1639 service_environment.get("SERVICE_TOKEN"),
1640 Some(&"abc123".to_string())
1641 );
1642 assert!(healed_content.is_some());
1643 }
1644
1645 #[test]
1646 fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1647 let yaml = r#"
1648services:
1649 - name: api
1650 systemd: athena.service
1651 systemd_service_name: athena
1652"#;
1653 let fixes = detect_xbp_config_heal_opportunities(yaml);
1654 assert_eq!(fixes.len(), 1);
1655 assert!(fixes[0].contains("athena.service"));
1656 }
1657
1658 #[test]
1659 fn heals_systemd_string_shorthand_in_yaml() {
1660 let yaml = r#"
1661project_name: athena
1662port: 3000
1663build_dir: $HOME/athena
1664systemd: athena.service
1665services:
1666 - name: api
1667 target: rust
1668 branch: main
1669 port: 3001
1670 systemd: athena-api.service
1671"#;
1672
1673 let (config, healed_content) =
1674 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1675
1676 assert_eq!(
1677 config.systemd_service_name.as_deref(),
1678 Some("athena.service")
1679 );
1680 assert!(config.systemd.is_none());
1681
1682 let service = &config.services.expect("services should exist")[0];
1683 assert_eq!(
1684 service.systemd_service_name.as_deref(),
1685 Some("athena-api.service")
1686 );
1687 assert!(service.systemd.is_none());
1688 assert!(healed_content.is_some());
1689 assert!(!healed_content
1690 .expect("healed content should exist")
1691 .contains("systemd: athena"));
1692 }
1693
1694 #[test]
1695 fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1696 let yaml = r#"
1697project_name: athena
1698port: 4052
1699build_dir: $HOME/athena
1700services:
1701 - name: athena-gateway
1702 target: rust
1703 branch: main
1704 port: 4052
1705 systemd: athena.service
1706 systemd_service_name: athena
1707"#;
1708
1709 let (config, healed_content) =
1710 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1711
1712 let service = &config.services.expect("services should exist")[0];
1713 assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1714 assert!(service.systemd.is_none());
1715 assert!(healed_content.is_some());
1716 }
1717
1718 #[test]
1719 fn heals_non_string_environment_values_in_json() {
1720 let json = r#"{
1721 "project_name": "demo",
1722 "port": 3000,
1723 "build_dir": "$HOME/demo",
1724 "environment": {
1725 "PORT": 3000,
1726 "DEBUG": true,
1727 "EMPTY": null
1728 }
1729}"#;
1730
1731 let (config, healed_content) =
1732 parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1733
1734 let environment = config.environment.expect("top-level env should exist");
1735 assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1736 assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1737 assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1738 assert!(healed_content.is_some());
1739 }
1740
1741 #[test]
1742 fn command_helpers_respect_path_order() {
1743 let bin_dir = make_temp_path("bin");
1744 fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1745 fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1746 fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1747
1748 with_path(std::slice::from_ref(&bin_dir), || {
1749 assert!(command_exists("python"));
1750 assert_eq!(
1751 first_available_command(&["python3", "python"]),
1752 Some("python".to_string())
1753 );
1754 assert_eq!(preferred_python_command(), "python".to_string());
1755 assert_eq!(preferred_pip_command(), "pip".to_string());
1756 });
1757
1758 fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1759 }
1760
1761 #[test]
1762 fn resolves_xbp_project_for_nested_paths() {
1763 let project_root = make_temp_path("ownership");
1764 let service_dir = project_root.join("services").join("api");
1765 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1766 fs::create_dir_all(&service_dir).expect("service dir should be created");
1767 fs::write(
1768 project_root.join(".xbp").join("xbp.json"),
1769 br#"{"project_name":"demo"}"#,
1770 )
1771 .expect("xbp config should be written");
1772
1773 let known_projects = vec![KnownXbpProject {
1774 root: project_root.clone(),
1775 name: "demo".to_string(),
1776 }];
1777
1778 assert_eq!(
1779 resolve_xbp_project_for_path(&service_dir, &known_projects),
1780 Some("demo".to_string())
1781 );
1782
1783 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1784 }
1785
1786 #[test]
1787 fn ignores_non_xbp_paths_for_project_resolution() {
1788 let project_root = make_temp_path("ownership-miss");
1789 let other_dir = make_temp_path("ownership-other");
1790 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1791 fs::create_dir_all(&other_dir).expect("other dir should be created");
1792 fs::write(
1793 project_root.join(".xbp").join("xbp.json"),
1794 br#"{"project_name":"demo"}"#,
1795 )
1796 .expect("xbp config should be written");
1797
1798 let known_projects = vec![KnownXbpProject {
1799 root: project_root.clone(),
1800 name: "demo".to_string(),
1801 }];
1802
1803 assert_eq!(
1804 resolve_xbp_project_for_path(&other_dir, &known_projects),
1805 None
1806 );
1807
1808 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1809 fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1810 }
1811
1812 #[test]
1813 fn auto_converts_legacy_json_to_yaml() {
1814 let project_root = make_temp_path("json-to-yaml");
1815 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1816 let json_path = project_root.join(".xbp").join("xbp.json");
1817 fs::write(
1818 &json_path,
1819 br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1820 )
1821 .expect("json should be written");
1822
1823 let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1824 .expect("conversion should succeed")
1825 .expect("yaml path should be returned");
1826 assert!(output.exists(), "converted yaml should exist");
1827
1828 let yaml = fs::read_to_string(output).expect("yaml should be readable");
1829 assert!(yaml.contains("project_name: demo"));
1830
1831 fs::remove_dir_all(project_root).expect("temp project should be removed");
1832 }
1833
1834 #[test]
1835 fn normalizes_environment_quotes_in_yaml_output() {
1836 let yaml = super::normalize_xbp_yaml_environment_quotes(
1837 r#"project_name: demo
1838port: 3000
1839build_dir: ./
1840environment:
1841 API_PATH: /api/auth
1842 WITH_DOUBLE: 'look "here"'
1843 WITH_SINGLE: "can't fail"
1844services:
1845 - name: api
1846 target: rust
1847 branch: main
1848 port: 3001
1849 environment:
1850 CALLBACK_PATH: /api/auth/callback
1851 CALLBACK_TITLE: "the 'quoted' callback"
1852"#,
1853 );
1854
1855 assert!(yaml.contains("API_PATH: \"/api/auth\""));
1856 assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1857 assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1858 assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1859 assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1860 }
1861
1862 #[test]
1863 fn writes_json_from_yaml_config() {
1864 let project_root = make_temp_path("yaml-to-json");
1865 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1866 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1867 let json_out = project_root.join("xbp.json");
1868 fs::write(
1869 &yaml_path,
1870 "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1871 )
1872 .expect("yaml should be written");
1873
1874 write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1875 .expect("json should be generated from yaml");
1876
1877 let json = fs::read_to_string(json_out).expect("json should be readable");
1878 assert!(json.contains("\"project_name\": \"demo\""));
1879
1880 fs::remove_dir_all(project_root).expect("temp project should be removed");
1881 }
1882
1883 #[test]
1884 fn parses_github_repo_from_supported_remote_urls() {
1885 assert_eq!(
1886 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1887 Some(("xylex-group".to_string(), "xbp".to_string()))
1888 );
1889 assert_eq!(
1890 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1891 Some(("xylex-group".to_string(), "xbp".to_string()))
1892 );
1893 assert_eq!(
1894 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1895 Some(("xylex-group".to_string(), "xbp".to_string()))
1896 );
1897 assert_eq!(
1898 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1899 None
1900 );
1901 assert_eq!(
1902 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1903 None
1904 );
1905 }
1906
1907 #[test]
1908 fn redacts_credentials_in_remote_urls() {
1909 let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1910 assert!(redacted.contains("REDACTED"));
1911 assert!(!redacted.contains("token@example.com"));
1912 }
1913
1914 #[test]
1915 fn reads_origin_remote_url_from_git_config_directory() {
1916 let project_root = make_temp_path("git-config-dir");
1917 let git_dir = project_root.join(".git");
1918 fs::create_dir_all(&git_dir).expect("git dir should be created");
1919 fs::write(
1920 git_dir.join("config"),
1921 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1922 )
1923 .expect("git config should be written");
1924
1925 let remote = git_remote_url_from_metadata(&project_root, "origin")
1926 .expect("git metadata should parse")
1927 .expect("origin remote should exist");
1928 assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1929
1930 fs::remove_dir_all(project_root).expect("temp project should be removed");
1931 }
1932
1933 #[test]
1934 fn reads_origin_remote_url_from_gitdir_pointer() {
1935 let project_root = make_temp_path("git-config-file");
1936 let nested_git_dir = project_root.join(".git-real");
1937 fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1938 fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1939 .expect("gitdir pointer should be written");
1940 fs::write(
1941 nested_git_dir.join("config"),
1942 "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1943 )
1944 .expect("git config should be written");
1945
1946 let remote = git_remote_url_from_metadata(&project_root, "origin")
1947 .expect("git metadata should parse")
1948 .expect("origin remote should exist");
1949 assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1950
1951 fs::remove_dir_all(project_root).expect("temp project should be removed");
1952 }
1953
1954 #[test]
1955 fn reads_origin_remote_url_from_ancestor_git_dir() {
1956 let repo_root = make_temp_path("git-ancestor-repo");
1958 let package_root = repo_root.join("packages").join("athena-auth-ui");
1959 fs::create_dir_all(&package_root).expect("package dir should be created");
1960 let git_dir = repo_root.join(".git");
1961 fs::create_dir_all(&git_dir).expect("git dir should be created");
1962 fs::write(
1963 git_dir.join("config"),
1964 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
1965 )
1966 .expect("git config should be written");
1967
1968 let remote = git_remote_url_from_metadata(&package_root, "origin")
1969 .expect("git metadata should parse")
1970 .expect("origin remote should exist via ancestor .git");
1971 assert_eq!(remote, "https://github.com/xylex-group/athena.git");
1972
1973 fs::remove_dir_all(repo_root).expect("temp project should be removed");
1974 }
1975
1976 #[test]
1977 fn durable_object_config_round_trips_yaml_json_jsonc_and_toml() {
1978 let value = serde_json::json!({
1979 "project_name": "worker",
1980 "port": 8787,
1981 "build_dir": ".",
1982 "workers": [{
1983 "name": "auth",
1984 "root": "apps/auth",
1985 "durable_objects": {
1986 "bindings": [{"name": "AUTH", "class_name": "AuthContainer", "script_name": "auth", "environment": "production"}],
1987 "migrations": [{"tag": "v1", "new_sqlite_classes": ["AuthContainer"]}],
1988 "namespaces": [{"id": "namespace-id", "name": "auth_AuthContainer", "script": "auth", "class": "AuthContainer", "use_sqlite": true, "use_containers": true}]
1989 },
1990 "containers": [{"class_name": "AuthContainer", "image": "registry.example/auth:latest", "build_context": ".", "instance_type": "standard", "max_instances": 2, "regions": ["WEUR"]}]
1991 }]
1992 });
1993 for kind in ["yaml", "json", "jsonc", "toml"] {
1994 let rendered = render_xbp_config_value(&value, kind).expect("render config");
1995 let (parsed, _) =
1996 parse_config_with_auto_heal::<Value>(&rendered, kind).expect("parse config");
1997 assert_eq!(
1998 parsed["workers"][0]["durable_objects"]["bindings"][0]["class_name"],
1999 "AuthContainer"
2000 );
2001 assert_eq!(
2002 parsed["workers"][0]["durable_objects"]["namespaces"][0]["use_sqlite"],
2003 true
2004 );
2005 assert_eq!(parsed["workers"][0]["containers"][0]["max_instances"], 2);
2006 }
2007 }
2008}