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}
783
784impl ConfigParseNormalizeMeta {
785 fn needs_rewrite(&self) -> bool {
786 self.had_bom || self.multi_document
787 }
788}
789
790fn parse_yaml_config_documents(
797 content: &str,
798) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
799 let had_bom = content.starts_with('\u{feff}');
800 let stripped = strip_utf8_bom(content);
801
802 let mut documents = Vec::new();
803 for document in serde_yaml::Deserializer::from_str(stripped) {
804 let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
805 if !matches!(value, serde_yaml::Value::Null) {
807 documents.push(value);
808 }
809 }
810
811 if documents.is_empty() {
812 return Ok((
813 serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
814 ConfigParseNormalizeMeta {
815 had_bom,
816 multi_document: false,
817 },
818 ));
819 }
820
821 let multi_document = documents.len() > 1;
822 let merged = if multi_document {
823 merge_yaml_values(documents)
824 } else {
825 documents
826 .into_iter()
827 .next()
828 .unwrap_or(serde_yaml::Value::Null)
829 };
830
831 Ok((
832 merged,
833 ConfigParseNormalizeMeta {
834 had_bom,
835 multi_document,
836 },
837 ))
838}
839
840fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
841 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
842 for document in documents {
843 deep_merge_yaml(&mut merged, document);
844 }
845 merged
846}
847
848fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
851 match (base, incoming) {
852 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
853 for (key, value) in incoming_map {
854 match base_map.get_mut(&key) {
855 Some(existing) => deep_merge_yaml(existing, value),
856 None => {
857 base_map.insert(key, value);
858 }
859 }
860 }
861 }
862 (base_slot, incoming) => {
863 *base_slot = incoming;
864 }
865 }
866}
867
868pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
869 content: &str,
870 kind: &str,
871) -> Result<(T, Option<String>), String> {
872 let (mut value, structural_heal) = match kind {
873 "yaml" => {
874 let (yaml_value, meta) = parse_yaml_config_documents(content)?;
875 let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
876 (json, meta.needs_rewrite())
877 }
878 "json" => {
879 let had_bom = content.starts_with('\u{feff}');
880 let stripped = strip_utf8_bom(content);
881 let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
882 (json, had_bom)
883 }
884 _ => return Err(format!("Unsupported config kind: {}", kind)),
885 };
886
887 let field_healed = auto_heal_xbp_config_value(&mut value);
888 let healed = field_healed || structural_heal;
889 let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
890
891 let healed_content = if healed {
892 Some(match kind {
893 "yaml" => serialize_xbp_yaml(&value)?,
894 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
895 _ => unreachable!(),
896 })
897 } else {
898 None
899 };
900
901 Ok((parsed, healed_content))
902}
903
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub struct XbpConfigHealResult {
906 pub config_path: PathBuf,
907 pub fixes: Vec<String>,
908}
909
910pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
911 let mut fixes = Vec::new();
912
913 if content.starts_with('\u{feff}') {
914 fixes.push("strip leading UTF-8 BOM".to_string());
915 }
916
917 let stripped = strip_utf8_bom(content);
918 if content_looks_like_yaml_multidoc(stripped) {
920 fixes.push("collapse multi-document YAML into a single document".to_string());
921 }
922
923 for line in content.lines() {
924 let trimmed = line.trim();
925 if !trimmed.starts_with("systemd:") {
926 continue;
927 }
928 let value = trimmed.trim_start_matches("systemd:").trim();
929 if value.is_empty() || value == "null" || value.starts_with('{') {
930 continue;
931 }
932 let normalized = value.trim_matches('"').trim_matches('\'');
933 fixes.push(format!(
934 "normalize `systemd: {normalized}` to `systemd_service_name`"
935 ));
936 }
937
938 fixes.sort();
939 fixes.dedup();
940 fixes
941}
942
943fn content_looks_like_yaml_multidoc(content: &str) -> bool {
944 let mut document_count = 0usize;
945 for document in serde_yaml::Deserializer::from_str(content) {
946 match serde_yaml::Value::deserialize(document) {
947 Ok(serde_yaml::Value::Null) => {}
948 Ok(_) => {
949 document_count += 1;
950 if document_count > 1 {
951 return true;
952 }
953 }
954 Err(_) => return false,
955 }
956 }
957 false
958}
959
960pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
961 let found = match find_xbp_config_upwards(start_dir) {
962 Some(found) => found,
963 None => return Ok(None),
964 };
965
966 let content = fs::read_to_string(&found.config_path)
967 .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
968 let fixes = detect_xbp_config_heal_opportunities(&content);
969 let healed = heal_config_file(&found.config_path, found.kind)?;
970
971 if !healed && fixes.is_empty() {
972 return Ok(None);
973 }
974
975 Ok(Some(XbpConfigHealResult {
976 config_path: found.config_path,
977 fixes: if fixes.is_empty() {
978 vec!["normalized legacy config fields".to_string()]
979 } else {
980 fixes
981 },
982 }))
983}
984
985pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
986 let content = fs::read_to_string(path)
987 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
988 let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
989
990 if let Some(healed_content) = healed_content {
991 fs::write(path, healed_content)
992 .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
993 return Ok(true);
994 }
995
996 Ok(false)
997}
998
999pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
1000 auto_heal_xbp_config_value(value)
1001}
1002
1003fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
1004 let Some(root) = value.as_object_mut() else {
1005 return false;
1006 };
1007
1008 let mut changed = false;
1009
1010 if let Some(environment) = root.get_mut("environment") {
1011 changed |= normalize_environment_value(environment);
1012 }
1013
1014 changed |= normalize_systemd_shorthand(root);
1015
1016 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
1017 for service in services {
1018 let Some(service) = service.as_object_mut() else {
1019 continue;
1020 };
1021
1022 if let Some(environment) = service.get_mut("environment") {
1023 changed |= normalize_environment_value(environment);
1024 }
1025
1026 changed |= normalize_systemd_shorthand(service);
1027 }
1028 }
1029
1030 changed
1031}
1032
1033fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
1034 let Some(systemd) = map.get("systemd") else {
1035 return false;
1036 };
1037
1038 let Value::String(service_name) = systemd else {
1039 return false;
1040 };
1041
1042 let systemd_service_name_missing = map
1043 .get("systemd_service_name")
1044 .map(|value| value.is_null())
1045 .unwrap_or(true);
1046
1047 if systemd_service_name_missing && !service_name.is_empty() {
1048 map.insert(
1049 "systemd_service_name".to_string(),
1050 Value::String(service_name.clone()),
1051 );
1052 }
1053
1054 map.remove("systemd");
1055 true
1056}
1057
1058fn normalize_environment_value(value: &mut Value) -> bool {
1059 let Value::Object(map) = value else {
1060 return false;
1061 };
1062
1063 let original = map.clone();
1064 let mut normalized = Map::new();
1065 let mut changed = false;
1066
1067 flatten_environment_entries(&original, &mut normalized, &mut changed);
1068
1069 if normalized != original {
1070 *map = normalized;
1071 changed = true;
1072 }
1073
1074 changed
1075}
1076
1077fn flatten_environment_entries(
1078 source: &Map<String, Value>,
1079 target: &mut Map<String, Value>,
1080 changed: &mut bool,
1081) {
1082 for (key, value) in source {
1083 match value {
1084 Value::String(string) => {
1085 target.insert(key.clone(), Value::String(string.clone()));
1086 }
1087 Value::Number(number) => {
1088 *changed = true;
1089 target.insert(key.clone(), Value::String(number.to_string()));
1090 }
1091 Value::Bool(boolean) => {
1092 *changed = true;
1093 target.insert(key.clone(), Value::String(boolean.to_string()));
1094 }
1095 Value::Null => {
1096 *changed = true;
1097 target.insert(key.clone(), Value::String(String::new()));
1098 }
1099 Value::Array(items) => {
1100 *changed = true;
1101 let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
1102 target.insert(key.clone(), Value::String(serialized));
1103 }
1104 Value::Object(nested) => {
1105 *changed = true;
1106 flatten_environment_entries(nested, target, changed);
1107 }
1108 }
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::{
1115 command_exists, detect_xbp_config_heal_opportunities, first_available_command,
1116 git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
1117 parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
1118 preferred_python_command, redact_remote_url_credentials, resolve_xbp_project_for_path,
1119 write_json_config_from_any_xbp_config, KnownXbpProject,
1120 };
1121 use crate::strategies::XbpConfig;
1122 use std::fs;
1123 use std::path::PathBuf;
1124 use std::sync::{Mutex, OnceLock};
1125
1126 fn path_lock() -> &'static Mutex<()> {
1127 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1128 LOCK.get_or_init(|| Mutex::new(()))
1129 }
1130
1131 fn make_temp_path(name: &str) -> PathBuf {
1132 let mut path = std::env::temp_dir();
1133 path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1134 path
1135 }
1136
1137 fn with_path<F>(entries: &[PathBuf], test: F)
1138 where
1139 F: FnOnce(),
1140 {
1141 let _guard = path_lock().lock().expect("path lock should be available");
1142 let original = std::env::var_os("PATH");
1143 let joined = std::env::join_paths(entries).expect("PATH entries should join");
1144 std::env::set_var("PATH", joined);
1145 test();
1146 match original {
1147 Some(path) => std::env::set_var("PATH", path),
1148 None => std::env::remove_var("PATH"),
1149 }
1150 }
1151
1152 #[test]
1153 fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
1154 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
1156
1157 let (config, healed_content) =
1158 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
1159
1160 assert_eq!(config.project_name, "demo");
1161 assert_eq!(config.port, 3000);
1162 let healed = healed_content.expect("BOM should trigger autonomous rewrite");
1163 assert!(
1164 !healed.starts_with('\u{feff}'),
1165 "healed yaml must not retain BOM"
1166 );
1167 assert!(healed.contains("project_name: demo"));
1168 }
1169
1170 #[test]
1171 fn merges_multi_document_yaml_and_rewrites_single_document() {
1172 let yaml = r#"
1173project_name: demo
1174port: 3000
1175build_dir: $HOME/demo
1176---
1177environment:
1178 LOG_LEVEL: info
1179---
1180services:
1181 - name: api
1182 target: rust
1183 branch: main
1184 port: 3001
1185"#;
1186
1187 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1188 .expect("multi-document yaml should merge");
1189
1190 assert_eq!(config.project_name, "demo");
1191 assert_eq!(config.port, 3000);
1192 let environment = config.environment.expect("merged environment should exist");
1193 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1194 let services = config.services.expect("merged services should exist");
1195 assert_eq!(services.len(), 1);
1196 assert_eq!(services[0].name, "api");
1197
1198 let healed = healed_content.expect("multi-doc should trigger rewrite");
1199 assert!(
1200 !healed.lines().any(|line| line.trim() == "---"),
1201 "healed yaml should be a single document"
1202 );
1203 assert!(healed.contains("project_name: demo"));
1204 assert!(healed.contains("LOG_LEVEL"));
1205 assert!(healed.contains("name: api"));
1206 }
1207
1208 #[test]
1209 fn parses_bom_prefixed_multi_document_yaml() {
1210 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
1211
1212 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1213 .expect("BOM + multi-doc yaml should parse");
1214
1215 assert_eq!(config.project_name, "demo");
1216 assert_eq!(config.version, "1.2.3");
1217 assert!(healed_content.is_some());
1218 }
1219
1220 #[test]
1221 fn detect_heal_opportunities_reports_bom_and_multidoc() {
1222 let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
1223 let fixes = detect_xbp_config_heal_opportunities(yaml);
1224 assert!(
1225 fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
1226 "expected BOM fix, got {fixes:?}"
1227 );
1228 assert!(
1229 fixes.iter().any(|fix| fix.contains("multi-document")),
1230 "expected multi-doc fix, got {fixes:?}"
1231 );
1232 assert!(
1233 fixes.iter().any(|fix| fix.contains("systemd")),
1234 "expected systemd fix, got {fixes:?}"
1235 );
1236 }
1237
1238 #[test]
1239 fn heal_config_file_strips_bom_on_disk() {
1240 let project_root = make_temp_path("heal-bom-yaml");
1241 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1242 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1243 fs::write(
1244 &yaml_path,
1245 "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1246 )
1247 .expect("yaml should be written with BOM");
1248
1249 let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
1250 assert!(healed, "BOM config should be rewritten");
1251
1252 let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
1253 assert!(
1254 !rewritten.starts_with('\u{feff}'),
1255 "rewritten file must not start with BOM"
1256 );
1257 assert!(rewritten.contains("project_name: demo"));
1258
1259 let (config, second_heal) =
1261 parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
1262 assert_eq!(config.project_name, "demo");
1263 assert!(second_heal.is_none());
1266
1267 fs::remove_dir_all(project_root).expect("temp project should be removed");
1268 }
1269
1270 #[test]
1271 fn heals_nested_yaml_environment_blocks() {
1272 let yaml = r#"
1273project_name: demo
1274port: 3000
1275build_dir: $HOME/demo
1276environment:
1277 production:
1278 DATABASE_URL: postgres://localhost/demo
1279 LOG_LEVEL: info
1280services:
1281 - name: api
1282 target: rust
1283 branch: main
1284 port: 3001
1285 environment:
1286 production:
1287 SERVICE_TOKEN: abc123
1288"#;
1289
1290 let (config, healed_content) =
1291 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1292
1293 let environment = config.environment.expect("top-level env should exist");
1294 assert_eq!(
1295 environment.get("DATABASE_URL"),
1296 Some(&"postgres://localhost/demo".to_string())
1297 );
1298 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1299
1300 let service_environment = config.services.expect("services should exist")[0]
1301 .environment
1302 .clone()
1303 .expect("service env should exist");
1304 assert_eq!(
1305 service_environment.get("SERVICE_TOKEN"),
1306 Some(&"abc123".to_string())
1307 );
1308 assert!(healed_content.is_some());
1309 }
1310
1311 #[test]
1312 fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1313 let yaml = r#"
1314services:
1315 - name: api
1316 systemd: athena.service
1317 systemd_service_name: athena
1318"#;
1319 let fixes = detect_xbp_config_heal_opportunities(yaml);
1320 assert_eq!(fixes.len(), 1);
1321 assert!(fixes[0].contains("athena.service"));
1322 }
1323
1324 #[test]
1325 fn heals_systemd_string_shorthand_in_yaml() {
1326 let yaml = r#"
1327project_name: athena
1328port: 3000
1329build_dir: $HOME/athena
1330systemd: athena.service
1331services:
1332 - name: api
1333 target: rust
1334 branch: main
1335 port: 3001
1336 systemd: athena-api.service
1337"#;
1338
1339 let (config, healed_content) =
1340 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1341
1342 assert_eq!(
1343 config.systemd_service_name.as_deref(),
1344 Some("athena.service")
1345 );
1346 assert!(config.systemd.is_none());
1347
1348 let service = &config.services.expect("services should exist")[0];
1349 assert_eq!(
1350 service.systemd_service_name.as_deref(),
1351 Some("athena-api.service")
1352 );
1353 assert!(service.systemd.is_none());
1354 assert!(healed_content.is_some());
1355 assert!(!healed_content
1356 .expect("healed content should exist")
1357 .contains("systemd: athena"));
1358 }
1359
1360 #[test]
1361 fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1362 let yaml = r#"
1363project_name: athena
1364port: 4052
1365build_dir: $HOME/athena
1366services:
1367 - name: athena-gateway
1368 target: rust
1369 branch: main
1370 port: 4052
1371 systemd: athena.service
1372 systemd_service_name: athena
1373"#;
1374
1375 let (config, healed_content) =
1376 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1377
1378 let service = &config.services.expect("services should exist")[0];
1379 assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1380 assert!(service.systemd.is_none());
1381 assert!(healed_content.is_some());
1382 }
1383
1384 #[test]
1385 fn heals_non_string_environment_values_in_json() {
1386 let json = r#"{
1387 "project_name": "demo",
1388 "port": 3000,
1389 "build_dir": "$HOME/demo",
1390 "environment": {
1391 "PORT": 3000,
1392 "DEBUG": true,
1393 "EMPTY": null
1394 }
1395}"#;
1396
1397 let (config, healed_content) =
1398 parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1399
1400 let environment = config.environment.expect("top-level env should exist");
1401 assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1402 assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1403 assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1404 assert!(healed_content.is_some());
1405 }
1406
1407 #[test]
1408 fn command_helpers_respect_path_order() {
1409 let bin_dir = make_temp_path("bin");
1410 fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1411 fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1412 fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1413
1414 with_path(std::slice::from_ref(&bin_dir), || {
1415 assert!(command_exists("python"));
1416 assert_eq!(
1417 first_available_command(&["python3", "python"]),
1418 Some("python".to_string())
1419 );
1420 assert_eq!(preferred_python_command(), "python".to_string());
1421 assert_eq!(preferred_pip_command(), "pip".to_string());
1422 });
1423
1424 fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1425 }
1426
1427 #[test]
1428 fn resolves_xbp_project_for_nested_paths() {
1429 let project_root = make_temp_path("ownership");
1430 let service_dir = project_root.join("services").join("api");
1431 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1432 fs::create_dir_all(&service_dir).expect("service dir should be created");
1433 fs::write(
1434 project_root.join(".xbp").join("xbp.json"),
1435 br#"{"project_name":"demo"}"#,
1436 )
1437 .expect("xbp config should be written");
1438
1439 let known_projects = vec![KnownXbpProject {
1440 root: project_root.clone(),
1441 name: "demo".to_string(),
1442 }];
1443
1444 assert_eq!(
1445 resolve_xbp_project_for_path(&service_dir, &known_projects),
1446 Some("demo".to_string())
1447 );
1448
1449 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1450 }
1451
1452 #[test]
1453 fn ignores_non_xbp_paths_for_project_resolution() {
1454 let project_root = make_temp_path("ownership-miss");
1455 let other_dir = make_temp_path("ownership-other");
1456 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1457 fs::create_dir_all(&other_dir).expect("other dir should be created");
1458 fs::write(
1459 project_root.join(".xbp").join("xbp.json"),
1460 br#"{"project_name":"demo"}"#,
1461 )
1462 .expect("xbp config should be written");
1463
1464 let known_projects = vec![KnownXbpProject {
1465 root: project_root.clone(),
1466 name: "demo".to_string(),
1467 }];
1468
1469 assert_eq!(
1470 resolve_xbp_project_for_path(&other_dir, &known_projects),
1471 None
1472 );
1473
1474 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1475 fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1476 }
1477
1478 #[test]
1479 fn auto_converts_legacy_json_to_yaml() {
1480 let project_root = make_temp_path("json-to-yaml");
1481 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1482 let json_path = project_root.join(".xbp").join("xbp.json");
1483 fs::write(
1484 &json_path,
1485 br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1486 )
1487 .expect("json should be written");
1488
1489 let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1490 .expect("conversion should succeed")
1491 .expect("yaml path should be returned");
1492 assert!(output.exists(), "converted yaml should exist");
1493
1494 let yaml = fs::read_to_string(output).expect("yaml should be readable");
1495 assert!(yaml.contains("project_name: demo"));
1496
1497 fs::remove_dir_all(project_root).expect("temp project should be removed");
1498 }
1499
1500 #[test]
1501 fn normalizes_environment_quotes_in_yaml_output() {
1502 let yaml = super::normalize_xbp_yaml_environment_quotes(
1503 r#"project_name: demo
1504port: 3000
1505build_dir: ./
1506environment:
1507 API_PATH: /api/auth
1508 WITH_DOUBLE: 'look "here"'
1509 WITH_SINGLE: "can't fail"
1510services:
1511 - name: api
1512 target: rust
1513 branch: main
1514 port: 3001
1515 environment:
1516 CALLBACK_PATH: /api/auth/callback
1517 CALLBACK_TITLE: "the 'quoted' callback"
1518"#,
1519 );
1520
1521 assert!(yaml.contains("API_PATH: \"/api/auth\""));
1522 assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1523 assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1524 assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1525 assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1526 }
1527
1528 #[test]
1529 fn writes_json_from_yaml_config() {
1530 let project_root = make_temp_path("yaml-to-json");
1531 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1532 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1533 let json_out = project_root.join("xbp.json");
1534 fs::write(
1535 &yaml_path,
1536 "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1537 )
1538 .expect("yaml should be written");
1539
1540 write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1541 .expect("json should be generated from yaml");
1542
1543 let json = fs::read_to_string(json_out).expect("json should be readable");
1544 assert!(json.contains("\"project_name\": \"demo\""));
1545
1546 fs::remove_dir_all(project_root).expect("temp project should be removed");
1547 }
1548
1549 #[test]
1550 fn parses_github_repo_from_supported_remote_urls() {
1551 assert_eq!(
1552 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1553 Some(("xylex-group".to_string(), "xbp".to_string()))
1554 );
1555 assert_eq!(
1556 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1557 Some(("xylex-group".to_string(), "xbp".to_string()))
1558 );
1559 assert_eq!(
1560 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1561 Some(("xylex-group".to_string(), "xbp".to_string()))
1562 );
1563 assert_eq!(
1564 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1565 None
1566 );
1567 assert_eq!(
1568 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1569 None
1570 );
1571 }
1572
1573 #[test]
1574 fn redacts_credentials_in_remote_urls() {
1575 let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1576 assert!(redacted.contains("REDACTED"));
1577 assert!(!redacted.contains("token@example.com"));
1578 }
1579
1580 #[test]
1581 fn reads_origin_remote_url_from_git_config_directory() {
1582 let project_root = make_temp_path("git-config-dir");
1583 let git_dir = project_root.join(".git");
1584 fs::create_dir_all(&git_dir).expect("git dir should be created");
1585 fs::write(
1586 git_dir.join("config"),
1587 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1588 )
1589 .expect("git config should be written");
1590
1591 let remote = git_remote_url_from_metadata(&project_root, "origin")
1592 .expect("git metadata should parse")
1593 .expect("origin remote should exist");
1594 assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1595
1596 fs::remove_dir_all(project_root).expect("temp project should be removed");
1597 }
1598
1599 #[test]
1600 fn reads_origin_remote_url_from_gitdir_pointer() {
1601 let project_root = make_temp_path("git-config-file");
1602 let nested_git_dir = project_root.join(".git-real");
1603 fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1604 fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1605 .expect("gitdir pointer should be written");
1606 fs::write(
1607 nested_git_dir.join("config"),
1608 "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1609 )
1610 .expect("git config should be written");
1611
1612 let remote = git_remote_url_from_metadata(&project_root, "origin")
1613 .expect("git metadata should parse")
1614 .expect("origin remote should exist");
1615 assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1616
1617 fs::remove_dir_all(project_root).expect("temp project should be removed");
1618 }
1619}