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