1use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8
9use crate::config::Config;
10
11#[derive(Debug, Clone)]
12pub struct Workspace {
13 pub root: PathBuf,
15 pub run_dir: PathBuf,
17}
18
19impl Workspace {
20 pub fn find(start: &Path) -> Result<Self> {
22 if let Some(forced) = env::var("RUN_WORKSPACE_DIR").ok().filter(|v| !v.is_empty()) {
25 let root = PathBuf::from(&forced)
26 .canonicalize()
27 .with_context(|| format!("RUN_WORKSPACE_DIR is not a directory: {forced}"))?;
28 return Ok(Self::at(root));
29 }
30 let mut dir = start
31 .canonicalize()
32 .with_context(|| format!("reading {}", start.display()))?;
33 loop {
34 if has_config(&dir) {
35 return Ok(Self::at(dir));
36 }
37 if dir.join("run/run.sh").is_file() {
39 bail!(
40 "{} uses the older run/ layout — convert it with `run-stack migrate` \
41 (the shell version), then this one can read it",
42 dir.display()
43 );
44 }
45 match dir.parent() {
46 Some(parent) if parent != dir => dir = parent.to_path_buf(),
47 _ => break,
48 }
49 }
50 bail!(
51 "no workspace here — nothing above {} has .run/run.config.toml",
52 start.display()
53 )
54 }
55
56 fn at(root: PathBuf) -> Self {
57 let run_dir = root.join(".run");
58 Self { root, run_dir }
59 }
60
61 pub fn toml_config_path(&self) -> PathBuf {
63 self.run_dir.join("run.config.toml")
64 }
65
66 pub fn config_path(&self) -> PathBuf {
68 let toml = self.toml_config_path();
69 if toml.is_file() {
70 return toml;
71 }
72 let json = self.run_dir.join("run.config.json");
73 if json.is_file() {
74 return json;
75 }
76 let legacy = self.root.join("run.config.json");
77 if legacy.is_file() {
78 return legacy;
79 }
80 let legacy_toml = self.root.join("run.config.toml");
81 if legacy_toml.is_file() {
82 return legacy_toml;
83 }
84 toml
85 }
86
87 pub fn env_path(&self) -> PathBuf {
88 self.run_dir.join(".env")
89 }
90
91 pub fn config(&self) -> Result<Config> {
92 let path = self.config_path();
93 let mut config = Config::load(&path)?;
94 let mut changed = config.ensure_essential();
95 let toml_path = self.toml_config_path();
96 let from_json = path
97 .extension()
98 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
99 if from_json || changed || path != toml_path {
100 config.save(&toml_path)?;
101 changed = true;
102 if from_json && path != toml_path {
103 let _ = fs::remove_file(&path);
104 eprintln!(
105 "Migrated {} → {}",
106 path.display(),
107 toml_path.display()
108 );
109 }
110 }
111 let _ = changed;
112 Ok(config)
113 }
114}
115
116fn has_config(dir: &Path) -> bool {
117 dir.join(".run/run.config.toml").is_file()
118 || dir.join(".run/run.config.json").is_file()
119 || dir.join("run.config.toml").is_file()
120 || dir.join("run.config.json").is_file()
121}