Skip to main content

zoi_cli/cmd/
dev.rs

1//! Implementation of the `dev` command for project development environments.
2
3use std::collections::HashMap;
4use std::process::Command;
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use indicatif::MultiProgress;
9use zoi_project::config as project_config;
10
11use crate::pkg::{install, local, resolve, types};
12
13/// Runs the `dev` command to enter a development shell for a project.
14/// # Errors
15///
16/// Returns an error if the development shell cannot be entered or dependencies
17/// cannot be resolved.
18pub fn run(run_cmd: Option<String>, repo: Option<String>) -> Result<()> {
19    let is_repo = repo.is_some();
20    let _temp_dir = if let Some(repo_url) = repo {
21        let full_url = if repo_url.starts_with("http") || repo_url.contains('@')
22        {
23            repo_url
24        } else if let Some((provider, path)) = repo_url.split_once(':') {
25            match provider {
26                "gh" | "github" => format!("https://github.com/{path}.git"),
27                "gl" | "gitlab" => format!("https://gitlab.com/{path}.git"),
28                "cb" | "codeberg" => format!("https://codeberg.org/{path}.git"),
29                _ => return Err(anyhow!("Unknown provider: {provider}"))
30            }
31        } else {
32            format!("https://github.com/{repo_url}.git")
33        };
34
35        println!(
36            "{} Cloning repository: {}...",
37            "::".bold().blue(),
38            full_url.cyan()
39        );
40
41        let temp = tempfile::Builder::new().prefix("zoi-dev-").tempdir()?;
42        let status = Command::new("git")
43            .arg("clone")
44            .arg("--depth")
45            .arg("1")
46            .arg(full_url)
47            .arg(temp.path())
48            .status()?;
49
50        if !status.success() {
51            return Err(anyhow!("Failed to clone repository."));
52        }
53
54        std::env::set_current_dir(temp.path())?;
55        Some(temp)
56    } else {
57        None
58    };
59
60    let mut config = if is_repo {
61        project_config::load_with_env(&HashMap::new())?
62    } else {
63        project_config::load()?
64    };
65    zoi_project::lockfile::record_project_config(
66        &config,
67        std::path::Path::new("zoi.lua")
68    )?;
69    let resolved_imports =
70        zoi_project::imports::resolve(&mut config, std::path::Path::new("."))?;
71    zoi_project::lockfile::record_imports(&resolved_imports)?;
72    println!(
73        "{} Entering development shell for project: {}",
74        "::".bold().blue(),
75        config.name.cyan().bold()
76    );
77
78    let (graph, _non_zoi_deps) = install::resolver::resolve_dependency_graph(
79        &config.pkgs,
80        Some(types::Scope::Project),
81        false,
82        true,
83        true,
84        None,
85        true,
86        Some(config.clone())
87    )?;
88
89    let mut missing_nodes = HashMap::new();
90    for (id, node) in &graph.nodes {
91        let request_source = crate::pkg::local::package_source_string(
92            &node.registry_handle,
93            &node.pkg.repo,
94            &node.pkg.name,
95            node.sub_package.as_deref(),
96            &node.version
97        );
98        if let Ok(request) = resolve::parse_source_string(&request_source) {
99            let matches = crate::pkg::local::find_installed_manifests_matching(
100                &request,
101                types::Scope::Project
102            )?;
103            if !matches
104                .iter()
105                .any(|manifest| manifest.version == node.version)
106            {
107                missing_nodes.insert(id.clone(), node.clone());
108            }
109        } else {
110            missing_nodes.insert(id.clone(), node.clone());
111        }
112    }
113
114    let install_plan =
115        install::plan::create_install_plan(&missing_nodes, None, false)?;
116    if !install_plan.is_empty() {
117        use std::sync::Mutex;
118
119        use rayon::prelude::*;
120
121        println!(
122            "{} Ensuring project dependencies are installed...",
123            "::".bold().blue()
124        );
125
126        let m_prep = MultiProgress::new();
127        let prepared_nodes = Mutex::new(HashMap::new());
128
129        missing_nodes.par_iter().try_for_each(
130            |(pkg_id, node)| -> Result<()> {
131                let action = install_plan.get(pkg_id).ok_or_else(|| {
132                    anyhow!("Install action not found for: {pkg_id}")
133                })?;
134
135                let prepared = install::installer::prepare_node(
136                    node,
137                    action,
138                    Some(&m_prep),
139                    None,
140                    false,
141                    false
142                )?;
143
144                let mut lock = prepared_nodes.lock().map_err(|e| {
145                    anyhow!(
146                        "Prepared nodes mutex poisoned during preparation: {e}"
147                    )
148                })?;
149                lock.insert(pkg_id.clone(), prepared);
150                Ok(())
151            }
152        )?;
153
154        let m = indicatif::MultiProgress::new();
155        let stages = graph.toposort()?;
156        for stage in stages {
157            stage.into_par_iter().try_for_each(|pkg_id| -> Result<()> {
158                let prepared = {
159                    let lock = prepared_nodes.lock().map_err(|e| {
160                        anyhow!(
161                            "Prepared nodes mutex poisoned during install: {e}"
162                        )
163                    })?;
164                    lock.get(&pkg_id).cloned()
165                };
166
167                if let Some(prepared) = prepared {
168                    let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
169                        anyhow!("Package not found in graph: {pkg_id}")
170                    })?;
171
172                    install::installer::install_prepared_node(
173                        node,
174                        &prepared,
175                        Some(&m),
176                        true,
177                        true,
178                        true,
179                        false
180                    )?;
181                }
182                Ok(())
183            })?;
184        }
185    }
186
187    let mut env_vars: HashMap<String, String> = HashMap::new();
188
189    let mut bin_paths = Vec::new();
190    let mut lib_paths = Vec::new();
191    let mut include_paths = Vec::new();
192    let mut pkg_config_paths = Vec::new();
193
194    let sep = if cfg!(windows) { ";" } else { ":" };
195
196    for node in graph.nodes.values() {
197        let handle = &node.registry_handle;
198        let pkg = &node.pkg;
199        let package_dir =
200            local::get_package_dir(pkg.scope, handle, &pkg.repo, &pkg.name)?;
201        let version_dir = package_dir.join(&node.version);
202
203        let bin_dir = version_dir.join("bin");
204        if bin_dir.exists() {
205            bin_paths.push(bin_dir);
206        }
207
208        let lib_dir = version_dir.join("lib");
209        if lib_dir.exists() {
210            lib_paths.push(lib_dir.clone());
211            let pkgconfig_dir = lib_dir.join("pkgconfig");
212            if pkgconfig_dir.exists() {
213                pkg_config_paths.push(pkgconfig_dir);
214            }
215        }
216
217        let include_dir = version_dir.join("include");
218        if include_dir.exists() {
219            include_paths.push(include_dir);
220        }
221
222        let share_dir = version_dir.join("share");
223        if share_dir.exists() {
224            let pkgconfig_dir = share_dir.join("pkgconfig");
225            if pkgconfig_dir.exists() {
226                pkg_config_paths.push(pkgconfig_dir);
227            }
228        }
229    }
230
231    if !bin_paths.is_empty() {
232        let mut path = bin_paths
233            .iter()
234            .map(|p| p.to_string_lossy().to_string())
235            .collect::<Vec<_>>()
236            .join(sep);
237        if let Ok(old_path) = std::env::var("PATH") {
238            path = format!("{path}{sep}{old_path}");
239        }
240        env_vars.insert("PATH".to_string(), path);
241    }
242
243    if !lib_paths.is_empty() {
244        let lib_path_var = if cfg!(target_os = "macos") {
245            "DYLD_LIBRARY_PATH"
246        } else {
247            "LD_LIBRARY_PATH"
248        };
249        let mut path = lib_paths
250            .iter()
251            .map(|p| p.to_string_lossy().to_string())
252            .collect::<Vec<_>>()
253            .join(sep);
254        if let Ok(old_path) = std::env::var(lib_path_var) {
255            path = format!("{path}{sep}{old_path}");
256        }
257        env_vars.insert(lib_path_var.to_string(), path);
258    }
259
260    if !include_paths.is_empty() {
261        let path = include_paths
262            .iter()
263            .map(|p| p.to_string_lossy().to_string())
264            .collect::<Vec<_>>()
265            .join(sep);
266        for var in &["CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH"] {
267            let mut full_path = path.clone();
268            if let Ok(old_path) = std::env::var(var) {
269                full_path = format!("{full_path}{sep}{old_path}");
270            }
271            env_vars.insert(var.to_string(), full_path);
272        }
273    }
274
275    if !pkg_config_paths.is_empty() {
276        let mut path = pkg_config_paths
277            .iter()
278            .map(|p| p.to_string_lossy().to_string())
279            .collect::<Vec<_>>()
280            .join(sep);
281        if let Ok(old_path) = std::env::var("PKG_CONFIG_PATH") {
282            path = format!("{path}{sep}{old_path}");
283        }
284        env_vars.insert("PKG_CONFIG_PATH".to_string(), path);
285    }
286
287    if let Some(shell_spec) = &config.shell {
288        let platform = crate::pkg::utils::get_platform()?;
289        let extra_env = match &shell_spec.env {
290            project_config::PlatformOrEnvMap::EnvMap(m) => m.clone(),
291            project_config::PlatformOrEnvMap::Platform(p) => p
292                .get(&platform)
293                .or_else(|| p.get("default"))
294                .cloned()
295                .unwrap_or_default()
296        };
297        for (k, v) in extra_env {
298            env_vars.insert(k, v);
299        }
300    }
301
302    if let Some(cmd_str) = run_cmd {
303        println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
304        let mut child = if cfg!(windows) {
305            Command::new("pwsh")
306                .arg("-Command")
307                .arg(&cmd_str)
308                .envs(&env_vars)
309                .spawn()?
310        } else {
311            Command::new("bash")
312                .arg("-c")
313                .arg(&cmd_str)
314                .envs(&env_vars)
315                .spawn()?
316        };
317        let status = child.wait()?;
318        if !status.success() {
319            std::process::exit(status.code().unwrap_or(1));
320        }
321    } else {
322        // `SHELL` is environment-controlled, so it goes through the validated
323        // resolver instead of being executed directly.
324        let shell_bin = zoi_core::utils::resolve_shell_binary()?;
325
326        println!(
327            "{} Entering dev shell (type 'exit' to leave)...",
328            "::".bold().green()
329        );
330
331        let mut child = Command::new(&shell_bin)
332            .envs(&env_vars)
333            .env("ZOI_SHELL", "dev")
334            .spawn()?;
335
336        let _ = child.wait()?;
337        println!("{} Exited dev shell.", "::".bold().blue());
338    }
339
340    Ok(())
341}