Skip to main content

stacksdapp_shell/
project.rs

1//! Project root discovery
2
3use serde::Deserialize;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7static PROJECT_ROOT: OnceLock<PathBuf> = OnceLock::new();
8
9pub const CONFIG_FILE: &str = "stacksdapp.toml";
10
11/// Optional project config loaded from `stacksdapp.toml`.
12#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
13pub struct StacksdappConfig {
14    pub project: Option<ProjectSection>,
15    pub defaults: Option<DefaultsSection>,
16}
17
18#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
19pub struct ProjectSection {
20    pub name: Option<String>,
21}
22
23#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
24pub struct DefaultsSection {
25    /// Default network for deploy/dev when not passed on the CLI (reserved for future use).
26    pub network: Option<String>,
27}
28
29/// Walk upward from `start` looking for a scaffold-stacks project root.
30///
31/// A directory qualifies if it contains either:
32/// - `stacksdapp.toml`, or
33/// - `contracts/Clarinet.toml` (scaffold-stacks layout)
34pub fn find_scaffold_root(start: &Path) -> Option<PathBuf> {
35    walk_up(start, is_scaffold_root)
36}
37
38/// Walk upward for scaffold root **or** a standard Clarinet root (`Clarinet.toml`).
39/// Used by `stacksdapp init`.
40pub fn find_init_root(start: &Path) -> Option<PathBuf> {
41    walk_up(start, |dir| {
42        is_scaffold_root(dir) || dir.join("Clarinet.toml").is_file()
43    })
44}
45
46fn is_scaffold_root(dir: &Path) -> bool {
47    dir.join(CONFIG_FILE).is_file() || dir.join("contracts").join("Clarinet.toml").is_file()
48}
49
50fn walk_up(start: &Path, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
51    let mut dir = if start.is_absolute() {
52        start.to_path_buf()
53    } else {
54        std::env::current_dir().ok()?.join(start)
55    };
56
57    // Best-effort canonicalize; keep walking even if it fails (e.g. missing path).
58    if let Ok(canon) = dir.canonicalize() {
59        dir = canon;
60    }
61
62    loop {
63        if predicate(&dir) {
64            return Some(dir);
65        }
66        if !dir.pop() {
67            return None;
68        }
69    }
70}
71
72/// Resolve project root: explicit override, else walk-up from cwd.
73pub fn resolve_scaffold_root(explicit: Option<&Path>) -> Result<PathBuf, String> {
74    if let Some(path) = explicit {
75        let root = if path.is_absolute() {
76            path.to_path_buf()
77        } else {
78            std::env::current_dir()
79                .map_err(|e| e.to_string())?
80                .join(path)
81        };
82        let root = root
83            .canonicalize()
84            .map_err(|e| format!("Invalid --root '{}': {e}", path.display()))?;
85        if !is_scaffold_root(&root) {
86            return Err(format!(
87                "Directory '{}' is not a stacksdapp project (expected {CONFIG_FILE} or contracts/Clarinet.toml).",
88                root.display()
89            ));
90        }
91        return Ok(root);
92    }
93
94    let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
95    find_scaffold_root(&cwd).ok_or_else(|| {
96        format!(
97            "No stacksdapp project found above '{}'.\n\
98             Looked for {CONFIG_FILE} or contracts/Clarinet.toml.\n\
99             Run `stacksdapp new <name>`, `stacksdapp init`, or pass --root <path>.",
100            cwd.display()
101        )
102    })
103}
104
105/// `chdir` into the project root and remember it for [`project_root`].
106pub fn enter_scaffold_root(explicit: Option<&Path>) -> Result<PathBuf, String> {
107    let root = resolve_scaffold_root(explicit)?;
108    std::env::set_current_dir(&root)
109        .map_err(|e| format!("Failed to enter project root '{}': {e}", root.display()))?;
110    let _ = PROJECT_ROOT.set(root.clone());
111    Ok(root)
112}
113
114/// Last root entered via [`enter_scaffold_root`], if any.
115pub fn project_root() -> Option<&'static Path> {
116    PROJECT_ROOT.get().map(|p| p.as_path())
117}
118
119/// Default network for deploy/dev when not passed on the CLI.
120pub fn validate_network(network: &str) -> Result<(), String> {
121    match network {
122        "devnet" | "testnet" | "mainnet" => Ok(()),
123        other => Err(format!(
124            "Invalid network '{other}'. Expected one of: devnet | testnet | mainnet"
125        )),
126    }
127}
128
129/// Load `stacksdapp.toml` from `root` if present (missing file → default config).
130pub fn load_config(root: &Path) -> Result<StacksdappConfig, String> {
131    let path = root.join(CONFIG_FILE);
132    if !path.is_file() {
133        return Ok(StacksdappConfig::default());
134    }
135    let raw = std::fs::read_to_string(&path)
136        .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
137    toml::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
138}
139
140/// Default contents for a new `stacksdapp.toml`.
141pub fn default_config_toml(project_name: &str) -> String {
142    format!(
143        r#"# stacksdapp project marker — enables root walk-up from subdirectories.
144# https://github.com/scaffold-stack/scaffold-stack
145
146[project]
147name = "{project_name}"
148
149# [defaults]
150# network = "devnet"
151"#
152    )
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::fs;
159
160    #[test]
161    fn finds_root_via_clarinet_toml() {
162        let tmp = tempfile::tempdir().unwrap();
163        let nested = tmp.path().join("a").join("b");
164        fs::create_dir_all(nested.join("x")).unwrap();
165        fs::create_dir_all(tmp.path().join("contracts")).unwrap();
166        fs::write(
167            tmp.path().join("contracts/Clarinet.toml"),
168            "[project]\nname=\"t\"\n",
169        )
170        .unwrap();
171
172        let found = find_scaffold_root(&nested.join("x")).unwrap();
173        assert_eq!(found, tmp.path().canonicalize().unwrap());
174    }
175
176    #[test]
177    fn prefers_stacksdapp_toml_marker() {
178        let tmp = tempfile::tempdir().unwrap();
179        let sub = tmp.path().join("frontend").join("src");
180        fs::create_dir_all(&sub).unwrap();
181        fs::write(tmp.path().join(CONFIG_FILE), "[project]\nname=\"demo\"\n").unwrap();
182
183        let found = find_scaffold_root(&sub).unwrap();
184        assert_eq!(found, tmp.path().canonicalize().unwrap());
185    }
186
187    #[test]
188    fn returns_none_when_missing() {
189        let tmp = tempfile::tempdir().unwrap();
190        let sub = tmp.path().join("empty");
191        fs::create_dir_all(&sub).unwrap();
192        assert!(find_scaffold_root(&sub).is_none());
193    }
194
195    #[test]
196    fn load_config_parses_name() {
197        let tmp = tempfile::tempdir().unwrap();
198        fs::write(
199            tmp.path().join(CONFIG_FILE),
200            "[project]\nname = \"my-dapp\"\n\n[defaults]\nnetwork = \"testnet\"\n",
201        )
202        .unwrap();
203        let cfg = load_config(tmp.path()).unwrap();
204        assert_eq!(cfg.project.unwrap().name.as_deref(), Some("my-dapp"));
205        assert_eq!(cfg.defaults.unwrap().network.as_deref(), Some("testnet"));
206    }
207
208    #[test]
209    fn init_root_finds_standard_clarinet() {
210        let tmp = tempfile::tempdir().unwrap();
211        let sub = tmp.path().join("contracts");
212        fs::create_dir_all(&sub).unwrap();
213        fs::write(tmp.path().join("Clarinet.toml"), "[project]\nname=\"c\"\n").unwrap();
214        let found = find_init_root(&sub).unwrap();
215        assert_eq!(found, tmp.path().canonicalize().unwrap());
216    }
217
218    #[test]
219    fn validate_network_accepts_known_values() {
220        assert!(validate_network("devnet").is_ok());
221        assert!(validate_network("testnet").is_ok());
222        assert!(validate_network("mainnet").is_ok());
223    }
224
225    #[test]
226    fn validate_network_rejects_unknown_values() {
227        assert!(validate_network("staging").is_err());
228        assert!(validate_network("").is_err());
229    }
230}