Skip to main content

run_stack/
workspace.rs

1//! Finding the workspace a command belongs to.
2
3use 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    /// The project root: the directory holding .run/.
14    pub root: PathBuf,
15    /// .run/ — config, generated compose overlays, the merged env file.
16    pub run_dir: PathBuf,
17}
18
19impl Workspace {
20    /// The nearest workspace at or above `start`, the way the shell walks up.
21    pub fn find(start: &Path) -> Result<Self> {
22        // Set but empty means "not set": a shell exports it that way when a
23        // caller clears it, and treating "" as a path fails uselessly.
24        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            // The layout before .run/: a run/ directory holding the scripts.
38            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    /// Preferred path for new writes.
62    pub fn toml_config_path(&self) -> PathBuf {
63        self.run_dir.join("run.config.toml")
64    }
65
66    /// Existing config file: toml preferred, then json (legacy).
67    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}