Skip to main content

stellar_scaffold_cli/commands/
clean.rs

1use crate::commands::build::clients::ScaffoldEnv;
2use crate::commands::build::env_toml::{self, Account, Environment};
3use cargo_metadata::Metadata;
4use clap::Parser;
5use std::{
6    fs, io,
7    path::{Path, PathBuf},
8    process::Command,
9};
10use stellar_cli::{commands::global, print::Print};
11/// A command to clean the scaffold-generated artifacts from a project
12#[derive(Parser, Debug, Clone)]
13pub struct Cmd {
14    /// Path to Cargo.toml
15    #[arg(long)]
16    pub manifest_path: Option<PathBuf>,
17}
18
19#[derive(thiserror::Error, Debug)]
20pub enum Error {
21    #[error(transparent)]
22    IO(#[from] io::Error),
23
24    #[error("network config is not sufficient: need name or url and passphrase")]
25    NetworkConfig,
26
27    #[error(transparent)]
28    EnvToml(#[from] env_toml::Error),
29}
30
31// cleans up scaffold artifacts
32// - target/stellar ✅
33// - packages/* (but not checked-into-git files like .gitkeep) ✅
34// - src/contracts/* (but not checked-into-git files like util.ts) ✅
35// - contract aliases (for local and test) ✅
36// - identity aliases (for local and test) ✅
37
38// - should this be deleting target/stellar/local and target/stellar/testnet specifically to avoid deleting mainnet?
39// - what about target/packages?
40// - what about target/wasm32v1-none/release/guess_the_number.wasm
41
42impl Cmd {
43    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
44        let printer = Print::new(global_args.quiet);
45        printer.infoln("Starting workspace cleanup");
46
47        let cargo_meta = match &self.manifest_path {
48            Some(manifest_path) => cargo_metadata::MetadataCommand::new()
49                .manifest_path(manifest_path)
50                .no_deps()
51                .exec()
52                .unwrap(),
53            _ => cargo_metadata::MetadataCommand::new()
54                .no_deps()
55                .exec()
56                .unwrap(),
57        };
58
59        Self::clean_target_stellar(&cargo_meta, &printer)?;
60
61        let workspace_root: PathBuf = cargo_meta.workspace_root.into();
62
63        Self::clean_packages(&workspace_root, &printer)?;
64
65        Self::clean_src_contracts(&workspace_root, &printer)?;
66
67        Self::clean_contract_aliases(&workspace_root, &printer)?;
68
69        Self::clean_identities(&workspace_root, &printer);
70
71        Ok(())
72    }
73
74    fn clean_target_stellar(cargo_meta: &Metadata, printer: &Print) -> Result<(), Error> {
75        let target_dir = &cargo_meta.target_directory;
76        let stellar_dir = target_dir.join("stellar");
77        if stellar_dir.exists() {
78            fs::remove_dir_all(&stellar_dir)?;
79        } else {
80            printer.infoln(format!(
81                "Skipping target clean: {stellar_dir} does not exist"
82            ));
83        }
84        Ok(())
85    }
86
87    fn clean_packages(workspace_root: &Path, printer: &Print) -> Result<(), Error> {
88        let packages_path: PathBuf = workspace_root.join("packages");
89        let git_tracked_packages_entries = Self::git_tracked_entries(workspace_root, "packages");
90        Self::clean_dir(
91            workspace_root,
92            &packages_path,
93            &git_tracked_packages_entries,
94            printer,
95        )
96    }
97
98    fn clean_src_contracts(workspace_root: &Path, printer: &Print) -> Result<(), Error> {
99        let src_contracts_path = workspace_root.join("src").join("contracts");
100        let git_tracked_src_contract_entries =
101            Self::git_tracked_entries(workspace_root, "src/contracts");
102        Self::clean_dir(
103            workspace_root,
104            &src_contracts_path,
105            &git_tracked_src_contract_entries,
106            printer,
107        )
108    }
109
110    fn clean_contract_aliases(workspace_root: &Path, printer: &Print) -> Result<(), Error> {
111        match Environment::get(workspace_root, &ScaffoldEnv::Development) {
112            Ok(Some(env)) => {
113                let network_args = Self::get_network_args(&env)?;
114                if let Some(contracts) = &env.contracts {
115                    for (contract_name, _) in contracts {
116                        let result = std::process::Command::new("stellar")
117                            .args(["contract", "alias", "remove", contract_name])
118                            .args(&network_args)
119                            .output();
120
121                        match result {
122                            Ok(output) if output.status.success() => {
123                                printer.infoln(format!("Removed contract alias: {contract_name}"));
124                            }
125                            Ok(output) => {
126                                let stderr = String::from_utf8_lossy(&output.stderr);
127                                if !stderr.contains("not found") && !stderr.contains("No alias") {
128                                    printer.warnln(format!(
129                                        "Failed to remove contract alias {contract_name}: {stderr}"
130                                    ));
131                                }
132                            }
133                            Err(e) => {
134                                printer.warnln(format!(
135                                    "Failed to execute stellar contract alias remove: {e}"
136                                ));
137                            }
138                        }
139                    }
140                }
141            }
142            Ok(None) => {
143                printer.infoln("No development environment found in environments.toml");
144            }
145            Err(e) => {
146                printer.warnln(format!("Failed to read environments.toml: {e}"));
147            }
148        }
149
150        Ok(())
151    }
152
153    fn clean_identities(workspace_root: &Path, printer: &Print) {
154        match Environment::get(workspace_root, &ScaffoldEnv::Development) {
155            Ok(Some(env)) => {
156                // only clean the alias if it is only configured for Development, otherwise warn
157                if let Some(accounts) = &env.accounts {
158                    for account in accounts {
159                        let other_envs = Self::account_in_other_envs(workspace_root, account);
160                        if !other_envs.is_empty() {
161                            printer.warnln(format!("Skipping cleaning identity {:?}. It is being used in other environments: {:?}.", account.name, other_envs));
162                            return;
163                        }
164
165                        let result = std::process::Command::new("stellar")
166                            .args(["keys", "rm", &account.name])
167                            .output();
168
169                        match result {
170                            Ok(output) if output.status.success() => {
171                                printer.infoln(format!("Removed account: {}", &account.name));
172                            }
173                            Ok(output) => {
174                                let stderr = String::from_utf8_lossy(&output.stderr);
175                                if !stderr.contains("not found") && !stderr.contains("No alias") {
176                                    printer.warnln(format!(
177                                        "Warning: Failed to remove account {}: {stderr}",
178                                        &account.name
179                                    ));
180                                }
181                            }
182                            Err(e) => {
183                                printer.warnln(format!("    Warning: Failed to execute stellar contract alias remove: {e}"));
184                            }
185                        }
186                    }
187                }
188            }
189            Ok(None) => {
190                printer.infoln("No development environment found in environments.toml");
191            }
192            Err(e) => {
193                printer.warnln(format!("Warning: Failed to read environments.toml: {e}"));
194            }
195        }
196    }
197
198    fn account_in_other_envs(workspace_root: &Path, current_account: &Account) -> Vec<ScaffoldEnv> {
199        let mut other_envs: Vec<ScaffoldEnv> = vec![];
200
201        if let Some(testing) = Environment::get(workspace_root, &ScaffoldEnv::Testing)
202            .ok()
203            .flatten()
204        {
205            let found = testing
206                .accounts
207                .as_ref()
208                .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name));
209            if found {
210                other_envs.push(ScaffoldEnv::Testing);
211            }
212        }
213
214        if let Some(staging) = Environment::get(workspace_root, &ScaffoldEnv::Staging)
215            .ok()
216            .flatten()
217        {
218            let found = staging
219                .accounts
220                .as_ref()
221                .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name));
222            if found {
223                other_envs.push(ScaffoldEnv::Staging);
224            }
225        }
226
227        if let Some(production) = Environment::get(workspace_root, &ScaffoldEnv::Production)
228            .ok()
229            .flatten()
230        {
231            let found = production
232                .accounts
233                .as_ref()
234                .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name));
235            if found {
236                other_envs.push(ScaffoldEnv::Production);
237            }
238        }
239
240        other_envs
241    }
242
243    fn get_network_args(env: &Environment) -> Result<Vec<&str>, Error> {
244        match (
245            &env.network.name,
246            &env.network.rpc_url,
247            &env.network.network_passphrase,
248        ) {
249            (Some(name), _, _) => Ok(vec!["--network", name]),
250            (None, Some(url), Some(passphrase)) => {
251                Ok(vec!["--rpc-url", url, "--network-passphrase", passphrase])
252            }
253            _ => Err(Error::NetworkConfig),
254        }
255    }
256
257    fn git_tracked_entries(workspace_root: &Path, subdir: &str) -> Vec<String> {
258        let output = Command::new("git")
259            .args(["ls-files", subdir])
260            .current_dir(workspace_root)
261            .output();
262
263        match output {
264            Ok(output) if output.status.success() => {
265                let stdout = String::from_utf8_lossy(&output.stdout);
266                stdout
267                    .lines()
268                    .map(std::string::ToString::to_string)
269                    .collect()
270            }
271            _ => {
272                // If git command fails, return empty list (no files will be preserved)
273                Vec::new()
274            }
275        }
276    }
277
278    // cleans the given directory while preserving git tracked files, as well as some common template files: utils.js and .gitkeep
279    fn clean_dir(
280        workspace_root: &Path,
281        dir_to_clean: &Path,
282        git_tracked_entries: &[String],
283        printer: &Print,
284    ) -> Result<(), Error> {
285        if dir_to_clean.exists() {
286            for entry in fs::read_dir(dir_to_clean)? {
287                let entry = entry?;
288                let path = entry.path();
289                let relative_path = path.strip_prefix(workspace_root).unwrap_or(&path);
290                let relative_str = relative_path.to_string_lossy().replace('\\', "/");
291
292                // Skip if this is a git-tracked file
293                if git_tracked_entries.contains(&relative_str) {
294                    continue;
295                }
296
297                // Preserve common template files regardless of git status
298                let filename = path.file_name().and_then(|n| n.to_str());
299                if let Some(name) = filename
300                    && (name == "util.ts" || name == ".gitkeep")
301                {
302                    continue;
303                }
304
305                // Remove the file or directory
306                if path.is_dir() {
307                    fs::remove_dir_all(&path).unwrap();
308                } else {
309                    fs::remove_file(&path).unwrap();
310                }
311                printer.infoln(format!("Removed {relative_str}"));
312            }
313        } else {
314            printer.infoln(format!(
315                "Skipping clean: {} does not exist",
316                dir_to_clean.display()
317            ));
318        }
319
320        Ok(())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::path::Path;
328    use tempfile::TempDir;
329
330    fn create_test_workspace(temp_dir: &Path) -> PathBuf {
331        let manifest_path = temp_dir.join("Cargo.toml");
332        fs::write(
333            &manifest_path,
334            r#"[package]
335name = "soroban-hello-world-contract"
336version = "0.0.0"
337edition = "2021"
338publish = false
339
340[lib]
341crate-type = ["cdylib"]
342"#,
343        )
344        .unwrap();
345
346        let src_dir = temp_dir.join("src");
347        fs::create_dir_all(&src_dir).unwrap();
348        fs::write(src_dir.join("lib.rs"), "// dummy lib").unwrap();
349
350        manifest_path
351    }
352
353    #[test]
354    fn test_clean_target_stellar() {
355        let global_args = global::Args::default();
356        let temp_dir = TempDir::new().unwrap();
357        let manifest_path = create_test_workspace(temp_dir.path());
358
359        let target_stellar_path = temp_dir.path().join("target").join("stellar");
360        std::fs::create_dir_all(&target_stellar_path).unwrap();
361
362        let cmd = Cmd {
363            manifest_path: Some(manifest_path),
364        };
365        assert!(cmd.run(&global_args).is_ok());
366
367        assert!(
368            !target_stellar_path.exists(),
369            "target/stellar should be removed"
370        );
371    }
372
373    #[test]
374    fn test_clean_packages() {
375        let global_args = global::Args::default();
376        let temp_dir = TempDir::new().unwrap();
377        let manifest_path = create_test_workspace(temp_dir.path());
378
379        let packages_path = temp_dir.path().join("packages");
380        let test_package_path = packages_path.join("test_contract_package");
381        std::fs::create_dir_all(&test_package_path).unwrap();
382
383        let gitkeep_path = packages_path.join(".gitkeep");
384        fs::write(&gitkeep_path, "").unwrap();
385
386        let cmd = Cmd {
387            manifest_path: Some(manifest_path),
388        };
389
390        assert!(cmd.run(&global_args).is_ok());
391
392        assert!(
393            !test_package_path.exists(),
394            "packages/test_contract_package/ should be removed"
395        );
396        assert!(
397            gitkeep_path.exists(),
398            "packages/.gitkeep should be preserved"
399        );
400    }
401
402    #[test]
403    fn test_clean_src_contracts() {
404        let global_args = global::Args::default();
405        let temp_dir = TempDir::new().unwrap();
406        let manifest_path = create_test_workspace(temp_dir.path());
407
408        let src_contracts_path = temp_dir.path().join("src").join("contracts");
409        std::fs::create_dir_all(&src_contracts_path).unwrap();
410
411        let test_contract_path = src_contracts_path.join("test_contract_client.js");
412        fs::write(&test_contract_path, "").unwrap();
413
414        let util_path = src_contracts_path.join("util.ts");
415        fs::write(&util_path, "").unwrap();
416
417        let cmd = Cmd {
418            manifest_path: Some(manifest_path),
419        };
420
421        assert!(cmd.run(&global_args).is_ok());
422
423        assert!(
424            !test_contract_path.exists(),
425            "src/contracts/test_contract_client.js should be removed"
426        );
427        assert!(
428            util_path.exists(),
429            "src/contracts/util.js should be preserved"
430        );
431    }
432}