1pub mod version;
7
8pub use version::{fetch_version, increment_version};
9
10use serde::de::DeserializeOwned;
11use serde_json::{Map, Value};
12use std::collections::{BTreeMap, HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use sysinfo::{Pid, System};
17
18use crate::profile::find_all_xbp_projects;
19
20#[derive(Debug, Clone)]
21pub struct FoundXbpConfig {
22 pub project_root: PathBuf,
23 pub config_path: PathBuf,
24 pub kind: &'static str,
25 pub location: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct KnownXbpProject {
30 pub root: PathBuf,
31 pub name: String,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct ListeningPortOwnership {
36 pub pids: Vec<u32>,
37 pub xbp_projects: Vec<String>,
38}
39
40pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
41 project_root.join(".xbp").join("xbp.yaml")
42}
43
44pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
45 let candidates = [
46 project_root.join(".xbp").join("xbp.yaml"),
47 project_root.join(".xbp").join("xbp.yml"),
48 project_root.join("xbp.yaml"),
49 project_root.join("xbp.yml"),
50 ];
51
52 candidates.into_iter().find(|candidate| candidate.exists())
53}
54
55pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
56 project_root: &Path,
57 config_path: &Path,
58) -> Result<Option<PathBuf>, String> {
59 if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
60 return Ok(None);
61 }
62
63 if !config_path.exists() {
64 return Ok(None);
65 }
66
67 if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
68 return Ok(Some(existing_yaml));
69 }
70
71 let content = fs::read_to_string(config_path).map_err(|e| {
72 format!(
73 "Failed to read legacy JSON config {}: {}",
74 config_path.display(),
75 e
76 )
77 })?;
78 let value: Value = serde_json::from_str(&content)
79 .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
80
81 let yaml_path = default_project_yaml_config_path(project_root);
82 if let Some(parent) = yaml_path.parent() {
83 fs::create_dir_all(parent).map_err(|e| {
84 format!(
85 "Failed to create config directory {}: {}",
86 parent.display(),
87 e
88 )
89 })?;
90 }
91
92 let yaml = serde_yaml::to_string(&value)
93 .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
94 fs::write(&yaml_path, yaml)
95 .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
96
97 Ok(Some(yaml_path))
98}
99
100pub fn write_json_config_from_any_xbp_config(
101 config_path: &Path,
102 output_json_path: &Path,
103) -> Result<(), String> {
104 let content = fs::read_to_string(config_path)
105 .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
106
107 let kind = if config_path
108 .extension()
109 .and_then(|ext| ext.to_str())
110 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
111 .unwrap_or(false)
112 {
113 "yaml"
114 } else {
115 "json"
116 };
117
118 let value = if kind == "yaml" {
119 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
120 .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
121 serde_json::to_value(yaml_value)
122 .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
123 } else {
124 serde_json::from_str::<Value>(&content)
125 .map_err(|e| format!("Failed to parse JSON config: {}", e))?
126 };
127
128 if let Some(parent) = output_json_path.parent() {
129 fs::create_dir_all(parent).map_err(|e| {
130 format!(
131 "Failed to create config directory {}: {}",
132 parent.display(),
133 e
134 )
135 })?;
136 }
137
138 let rendered = serde_json::to_string_pretty(&value)
139 .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
140 fs::write(output_json_path, rendered).map_err(|e| {
141 format!(
142 "Failed to write JSON config {}: {}",
143 output_json_path.display(),
144 e
145 )
146 })?;
147
148 Ok(())
149}
150
151pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
152 for dir in start_dir.ancestors() {
153 let candidates: [(PathBuf, &'static str); 6] = [
154 (dir.join(".xbp").join("xbp.yaml"), "yaml"),
155 (dir.join(".xbp").join("xbp.yml"), "yaml"),
156 (dir.join(".xbp").join("xbp.json"), "json"),
157 (dir.join("xbp.yaml"), "yaml"),
158 (dir.join("xbp.yml"), "yaml"),
159 (dir.join("xbp.json"), "json"),
160 ];
161
162 for (path, kind) in candidates {
163 if !path.exists() {
164 continue;
165 }
166
167 let project_root = path
168 .parent()
169 .and_then(|p| {
170 if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
171 p.parent().map(|pp| pp.to_path_buf())
172 } else {
173 Some(p.to_path_buf())
174 }
175 })
176 .unwrap_or_else(|| dir.to_path_buf());
177
178 let location = path
179 .strip_prefix(&project_root)
180 .ok()
181 .map(|p| p.to_string_lossy().replace('\\', "/"))
182 .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
183
184 return Some(FoundXbpConfig {
185 project_root,
186 config_path: path,
187 kind,
188 location,
189 });
190 }
191 }
192
193 None
194}
195
196pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
197 let mut projects = Vec::new();
198 let mut seen_roots = HashSet::new();
199
200 for project in find_all_xbp_projects() {
201 let root = canonicalize_or_fallback(&project.path);
202 if seen_roots.insert(root.clone()) {
203 projects.push(KnownXbpProject {
204 root,
205 name: project.name,
206 });
207 }
208 }
209
210 if let Ok(current_dir) = std::env::current_dir() {
211 if let Some(found) = find_xbp_config_upwards(¤t_dir) {
212 let root = canonicalize_or_fallback(&found.project_root);
213 if seen_roots.insert(root.clone()) {
214 let name = root
215 .file_name()
216 .and_then(|value| value.to_str())
217 .unwrap_or("current")
218 .to_string();
219 projects.push(KnownXbpProject { root, name });
220 }
221 }
222 }
223
224 projects.sort_by(|left, right| {
225 right
226 .root
227 .components()
228 .count()
229 .cmp(&left.root.components().count())
230 .then_with(|| left.name.cmp(&right.name))
231 });
232 projects
233}
234
235pub fn resolve_xbp_project_for_path(
236 candidate: &Path,
237 known_projects: &[KnownXbpProject],
238) -> Option<String> {
239 if candidate.as_os_str().is_empty() {
240 return None;
241 }
242
243 if let Some(found) = find_xbp_config_upwards(candidate) {
244 let found_root = canonicalize_or_fallback(&found.project_root);
245 if let Some(project) = known_projects
246 .iter()
247 .find(|project| canonicalize_or_fallback(&project.root) == found_root)
248 {
249 return Some(project.name.clone());
250 }
251
252 return found_root
253 .file_name()
254 .and_then(|value| value.to_str())
255 .map(|value| value.to_string());
256 }
257
258 let candidate_path = canonicalize_or_fallback(candidate);
259 known_projects
260 .iter()
261 .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
262 .map(|project| project.name.clone())
263}
264
265pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
266 use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
267
268 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
269 let proto_flags = ProtocolFlags::TCP;
270 let sockets = get_sockets_info(af_flags, proto_flags)
271 .map_err(|e| format!("Failed to get sockets info: {}", e))?;
272
273 let mut system = System::new_all();
274 system.refresh_all();
275 let known_projects = collect_known_xbp_projects();
276 let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
277 let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
278
279 for socket in sockets {
280 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
281 let state = format!("{:?}", tcp.state);
282 if state != "Listen" && state != "LISTEN" {
283 continue;
284 }
285
286 let row = ports.entry(tcp.local_port).or_default();
287 for pid in socket.associated_pids {
288 row.pids.push(pid);
289 if let Some(project) = pid_project_cache
290 .entry(pid)
291 .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
292 .clone()
293 {
294 row.xbp_projects.push(project);
295 }
296 }
297 }
298 }
299
300 for row in ports.values_mut() {
301 row.pids.sort_unstable();
302 row.pids.dedup();
303 row.xbp_projects.sort();
304 row.xbp_projects.dedup();
305 }
306
307 Ok(ports)
308}
309
310fn resolve_xbp_project_for_pid(
311 pid: u32,
312 system: &System,
313 known_projects: &[KnownXbpProject],
314) -> Option<String> {
315 let process = system.process(Pid::from_u32(pid))?;
316
317 if let Some(project) = process
318 .cwd()
319 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
320 {
321 return Some(project);
322 }
323
324 process
325 .exe()
326 .and_then(|path| path.parent())
327 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
328}
329
330fn canonicalize_or_fallback(path: &Path) -> PathBuf {
331 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
332}
333
334pub fn expand_home_in_string(input: &str) -> String {
335 let home = dirs::home_dir()
336 .unwrap_or_else(|| std::path::PathBuf::from("."))
337 .to_string_lossy()
338 .to_string();
339
340 if input == "~" {
341 return home;
342 }
343
344 if let Some(rest) = input
345 .strip_prefix("~/")
346 .or_else(|| input.strip_prefix("~\\"))
347 {
348 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
349 }
350
351 if let Some(rest) = input
352 .strip_prefix("$HOME/")
353 .or_else(|| input.strip_prefix("$HOME\\"))
354 {
355 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
356 }
357
358 if let Some(rest) = input
359 .strip_prefix("${HOME}/")
360 .or_else(|| input.strip_prefix("${HOME}\\"))
361 {
362 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
363 }
364
365 input.to_string()
366}
367
368pub fn collapse_home_to_env(input: &str) -> String {
369 let home = dirs::home_dir()
370 .unwrap_or_else(|| std::path::PathBuf::from("."))
371 .to_string_lossy()
372 .to_string();
373
374 if input == home {
375 return "$HOME".to_string();
376 }
377
378 if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
379 return format!("$HOME/{}", rest);
380 }
381
382 if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
383 return format!("$HOME\\{}", rest);
384 }
385
386 input.to_string()
387}
388
389pub fn command_exists(program: &str) -> bool {
390 let Some(path_var) = std::env::var_os("PATH") else {
391 return false;
392 };
393
394 for dir in std::env::split_paths(&path_var) {
395 let candidate = dir.join(program);
396 if candidate.is_file() {
397 return true;
398 }
399
400 #[cfg(windows)]
401 for ext in ["exe", "cmd", "bat"] {
402 let candidate = dir.join(format!("{}.{}", program, ext));
403 if candidate.is_file() {
404 return true;
405 }
406 }
407 }
408
409 false
410}
411
412pub fn first_available_command(candidates: &[&str]) -> Option<String> {
413 candidates
414 .iter()
415 .find(|candidate| command_exists(candidate))
416 .map(|candidate| (*candidate).to_string())
417}
418
419pub fn preferred_python_command() -> String {
420 first_available_command(&["python3", "python"]).unwrap_or_else(|| {
421 if cfg!(target_os = "windows") {
422 "python".to_string()
423 } else {
424 "python3".to_string()
425 }
426 })
427}
428
429pub fn preferred_pip_command() -> String {
430 first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
431 if cfg!(target_os = "windows") {
432 "pip".to_string()
433 } else {
434 "pip3".to_string()
435 }
436 })
437}
438
439pub fn open_with_default_handler(target: &str) -> Result<(), String> {
440 let mut command = if cfg!(target_os = "windows") {
441 let mut cmd = Command::new("cmd");
442 cmd.arg("/C").arg("start").arg("").arg(target);
443 cmd
444 } else if cfg!(target_os = "macos") {
445 let mut cmd = Command::new("open");
446 cmd.arg(target);
447 cmd
448 } else {
449 let mut cmd = Command::new("xdg-open");
450 cmd.arg(target);
451 cmd
452 };
453
454 command
455 .spawn()
456 .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
457 Ok(())
458}
459
460pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
461 if let Ok(editor) = std::env::var("EDITOR") {
462 let mut parts = editor.split_whitespace();
463 let binary = parts
464 .next()
465 .ok_or_else(|| "EDITOR is set but empty".to_string())?;
466 let mut command = Command::new(binary);
467 for part in parts {
468 command.arg(part);
469 }
470 command
471 .arg(path)
472 .spawn()
473 .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
474 return Ok(());
475 }
476
477 open_with_default_handler(&path.display().to_string())
478}
479
480pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
481 content: &str,
482 kind: &str,
483) -> Result<(T, Option<String>), String> {
484 let mut value = match kind {
485 "yaml" => {
486 let yaml_value: serde_yaml::Value =
487 serde_yaml::from_str(content).map_err(|e| e.to_string())?;
488 serde_json::to_value(yaml_value).map_err(|e| e.to_string())?
489 }
490 "json" => serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?,
491 _ => return Err(format!("Unsupported config kind: {}", kind)),
492 };
493
494 let healed = auto_heal_xbp_config_value(&mut value);
495 let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
496
497 let healed_content = if healed {
498 Some(match kind {
499 "yaml" => serde_yaml::to_string(&value).map_err(|e| e.to_string())?,
500 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
501 _ => unreachable!(),
502 })
503 } else {
504 None
505 };
506
507 Ok((parsed, healed_content))
508}
509
510pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
511 let content = fs::read_to_string(path)
512 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
513 let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
514
515 if let Some(healed_content) = healed_content {
516 fs::write(path, healed_content)
517 .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
518 return Ok(true);
519 }
520
521 Ok(false)
522}
523
524fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
525 let Some(root) = value.as_object_mut() else {
526 return false;
527 };
528
529 let mut changed = false;
530
531 if let Some(environment) = root.get_mut("environment") {
532 changed |= normalize_environment_value(environment);
533 }
534
535 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
536 for service in services {
537 if let Some(environment) = service
538 .as_object_mut()
539 .and_then(|service| service.get_mut("environment"))
540 {
541 changed |= normalize_environment_value(environment);
542 }
543 }
544 }
545
546 changed
547}
548
549fn normalize_environment_value(value: &mut Value) -> bool {
550 let Value::Object(map) = value else {
551 return false;
552 };
553
554 let original = map.clone();
555 let mut normalized = Map::new();
556 let mut changed = false;
557
558 flatten_environment_entries(&original, &mut normalized, &mut changed);
559
560 if normalized != original {
561 *map = normalized;
562 changed = true;
563 }
564
565 changed
566}
567
568fn flatten_environment_entries(
569 source: &Map<String, Value>,
570 target: &mut Map<String, Value>,
571 changed: &mut bool,
572) {
573 for (key, value) in source {
574 match value {
575 Value::String(string) => {
576 target.insert(key.clone(), Value::String(string.clone()));
577 }
578 Value::Number(number) => {
579 *changed = true;
580 target.insert(key.clone(), Value::String(number.to_string()));
581 }
582 Value::Bool(boolean) => {
583 *changed = true;
584 target.insert(key.clone(), Value::String(boolean.to_string()));
585 }
586 Value::Null => {
587 *changed = true;
588 target.insert(key.clone(), Value::String(String::new()));
589 }
590 Value::Array(items) => {
591 *changed = true;
592 let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
593 target.insert(key.clone(), Value::String(serialized));
594 }
595 Value::Object(nested) => {
596 *changed = true;
597 flatten_environment_entries(nested, target, changed);
598 }
599 }
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::{
606 command_exists, first_available_command, maybe_auto_convert_legacy_xbp_json_to_yaml,
607 parse_config_with_auto_heal, preferred_pip_command, preferred_python_command,
608 resolve_xbp_project_for_path, write_json_config_from_any_xbp_config, KnownXbpProject,
609 };
610 use crate::strategies::XbpConfig;
611 use std::fs;
612 use std::path::PathBuf;
613 use std::sync::{Mutex, OnceLock};
614
615 fn path_lock() -> &'static Mutex<()> {
616 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
617 LOCK.get_or_init(|| Mutex::new(()))
618 }
619
620 fn make_temp_path(name: &str) -> PathBuf {
621 let mut path = std::env::temp_dir();
622 path.push(format!("xbp-test-{}-{}", name, std::process::id()));
623 path
624 }
625
626 fn with_path<F>(entries: &[PathBuf], test: F)
627 where
628 F: FnOnce(),
629 {
630 let _guard = path_lock().lock().expect("path lock should be available");
631 let original = std::env::var_os("PATH");
632 let joined = std::env::join_paths(entries).expect("PATH entries should join");
633 std::env::set_var("PATH", joined);
634 test();
635 match original {
636 Some(path) => std::env::set_var("PATH", path),
637 None => std::env::remove_var("PATH"),
638 }
639 }
640
641 #[test]
642 fn heals_nested_yaml_environment_blocks() {
643 let yaml = r#"
644project_name: demo
645port: 3000
646build_dir: $HOME/demo
647environment:
648 production:
649 DATABASE_URL: postgres://localhost/demo
650 LOG_LEVEL: info
651services:
652 - name: api
653 target: rust
654 branch: main
655 port: 3001
656 environment:
657 production:
658 SERVICE_TOKEN: abc123
659"#;
660
661 let (config, healed_content) =
662 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
663
664 let environment = config.environment.expect("top-level env should exist");
665 assert_eq!(
666 environment.get("DATABASE_URL"),
667 Some(&"postgres://localhost/demo".to_string())
668 );
669 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
670
671 let service_environment = config.services.expect("services should exist")[0]
672 .environment
673 .clone()
674 .expect("service env should exist");
675 assert_eq!(
676 service_environment.get("SERVICE_TOKEN"),
677 Some(&"abc123".to_string())
678 );
679 assert!(healed_content.is_some());
680 }
681
682 #[test]
683 fn heals_non_string_environment_values_in_json() {
684 let json = r#"{
685 "project_name": "demo",
686 "port": 3000,
687 "build_dir": "$HOME/demo",
688 "environment": {
689 "PORT": 3000,
690 "DEBUG": true,
691 "EMPTY": null
692 }
693}"#;
694
695 let (config, healed_content) =
696 parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
697
698 let environment = config.environment.expect("top-level env should exist");
699 assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
700 assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
701 assert_eq!(environment.get("EMPTY"), Some(&String::new()));
702 assert!(healed_content.is_some());
703 }
704
705 #[test]
706 fn command_helpers_respect_path_order() {
707 let bin_dir = make_temp_path("bin");
708 fs::create_dir_all(&bin_dir).expect("temp dir should be created");
709 fs::write(bin_dir.join("python"), b"").expect("python file should be created");
710 fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
711
712 with_path(std::slice::from_ref(&bin_dir), || {
713 assert!(command_exists("python"));
714 assert_eq!(
715 first_available_command(&["python3", "python"]),
716 Some("python".to_string())
717 );
718 assert_eq!(preferred_python_command(), "python".to_string());
719 assert_eq!(preferred_pip_command(), "pip".to_string());
720 });
721
722 fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
723 }
724
725 #[test]
726 fn resolves_xbp_project_for_nested_paths() {
727 let project_root = make_temp_path("ownership");
728 let service_dir = project_root.join("services").join("api");
729 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
730 fs::create_dir_all(&service_dir).expect("service dir should be created");
731 fs::write(
732 project_root.join(".xbp").join("xbp.json"),
733 br#"{"project_name":"demo"}"#,
734 )
735 .expect("xbp config should be written");
736
737 let known_projects = vec![KnownXbpProject {
738 root: project_root.clone(),
739 name: "demo".to_string(),
740 }];
741
742 assert_eq!(
743 resolve_xbp_project_for_path(&service_dir, &known_projects),
744 Some("demo".to_string())
745 );
746
747 fs::remove_dir_all(&project_root).expect("temp project should be removed");
748 }
749
750 #[test]
751 fn ignores_non_xbp_paths_for_project_resolution() {
752 let project_root = make_temp_path("ownership-miss");
753 let other_dir = make_temp_path("ownership-other");
754 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
755 fs::create_dir_all(&other_dir).expect("other dir should be created");
756 fs::write(
757 project_root.join(".xbp").join("xbp.json"),
758 br#"{"project_name":"demo"}"#,
759 )
760 .expect("xbp config should be written");
761
762 let known_projects = vec![KnownXbpProject {
763 root: project_root.clone(),
764 name: "demo".to_string(),
765 }];
766
767 assert_eq!(
768 resolve_xbp_project_for_path(&other_dir, &known_projects),
769 None
770 );
771
772 fs::remove_dir_all(&project_root).expect("temp project should be removed");
773 fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
774 }
775
776 #[test]
777 fn auto_converts_legacy_json_to_yaml() {
778 let project_root = make_temp_path("json-to-yaml");
779 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
780 let json_path = project_root.join(".xbp").join("xbp.json");
781 fs::write(
782 &json_path,
783 br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
784 )
785 .expect("json should be written");
786
787 let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
788 .expect("conversion should succeed")
789 .expect("yaml path should be returned");
790 assert!(output.exists(), "converted yaml should exist");
791
792 let yaml = fs::read_to_string(output).expect("yaml should be readable");
793 assert!(yaml.contains("project_name: demo"));
794
795 fs::remove_dir_all(project_root).expect("temp project should be removed");
796 }
797
798 #[test]
799 fn writes_json_from_yaml_config() {
800 let project_root = make_temp_path("yaml-to-json");
801 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
802 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
803 let json_out = project_root.join("xbp.json");
804 fs::write(
805 &yaml_path,
806 "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
807 )
808 .expect("yaml should be written");
809
810 write_json_config_from_any_xbp_config(&yaml_path, &json_out)
811 .expect("json should be generated from yaml");
812
813 let json = fs::read_to_string(json_out).expect("json should be readable");
814 assert!(json.contains("\"project_name\": \"demo\""));
815
816 fs::remove_dir_all(project_root).expect("temp project should be removed");
817 }
818}