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