stacksdapp_shell/
project.rs1use 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#[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 pub network: Option<String>,
27}
28
29pub fn find_scaffold_root(start: &Path) -> Option<PathBuf> {
35 walk_up(start, is_scaffold_root)
36}
37
38pub 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 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
72pub 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
105pub 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
114pub fn project_root() -> Option<&'static Path> {
116 PROJECT_ROOT.get().map(|p| p.as_path())
117}
118
119pub 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
129pub 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
140pub 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}