Skip to main content

wargo_lib/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(
3    test(attr(allow(unused_variables), deny(warnings))),
4    // html_favicon_url = "https://raw.githubusercontent.com/asaaki/wargo/main/.assets/favicon.ico",
5    html_logo_url = "https://raw.githubusercontent.com/asaaki/wargo/main/.assets/logo-temp.png"
6)]
7#![cfg_attr(feature = "docs", feature(doc_cfg))]
8#![forbid(unsafe_code)]
9
10use anyhow::Context;
11use cargo_metadata::{Message, MetadataCommand, TargetKind};
12use cprint::cprintln;
13use filetime::{FileTime, set_symlink_file_times};
14use globwalk::DirEntry;
15use serde::Deserialize;
16use std::{
17    env,
18    ffi::OsStr,
19    fs,
20    path::{Path, PathBuf},
21    process::{Command, Stdio},
22    vec,
23};
24
25mod check;
26mod paths;
27mod progress;
28
29type GenericResult<T> = anyhow::Result<T>;
30pub type NullResult = GenericResult<()>;
31
32const SKIPPABLES: [&str; 4] = ["wargo", "cargo-wsl", "cargo", "wsl"];
33
34const HELP_TEXT: &str = r#"wargo
35
36cargo's evil twin to work with projects in the twilight zone of WSL2
37
38HELP TEXT WENT MISSING IN THE DARK …
39
40Maybe you find more helpful information at:
41
42https://github.com/asaaki/wargo
43"#;
44
45#[derive(Debug, Default, Deserialize)]
46#[serde(default, deny_unknown_fields)]
47struct WargoConfig {
48    /// optionally override the project folder name
49    /// in the destination base directory
50    project_dir: Option<String>,
51
52    /// optionally set a different destination base directory
53    dest_base_dir: Option<String>,
54
55    /// @deprecated - will be removed in v0.3
56    ignore_git: Option<bool>,
57
58    /// preferred since v0.2
59    /// de-Option with v0.3
60    include_git: Option<bool>,
61
62    /// @deprecated - will be removed in v0.3
63    ignore_target: Option<bool>,
64
65    /// preferred since v0.2
66    /// de-Option with v0.3
67    include_target: Option<bool>,
68
69    /// clean out the project folder before run
70    /// (will remove and recreate folder)
71    clean: bool,
72
73    /// internal option
74    #[serde(skip)]
75    clean_git: bool,
76}
77
78pub fn run(_from: &str) -> NullResult {
79    #[cfg(target_os = "windows")]
80    if wsl2_subshell()? {
81        cprintln!("wargo", "WSL2 subshell done.", Color::Cyan);
82        return Ok(());
83    }
84    check::wsl2_or_exit()?;
85
86    let args = parse_args();
87
88    if args.is_empty() || args[0] == "--help" {
89        println!("{HELP_TEXT}");
90        return Ok(());
91    }
92
93    let workspace_root = MetadataCommand::new()
94        .exec()?
95        .workspace_root
96        .into_std_path_buf()
97        .canonicalize()?;
98    let mut wargo_config = get_wargo_config(&workspace_root)?;
99    let dest_dir = get_destination_dir(&wargo_config, &workspace_root);
100
101    let entries = collect_entries(&mut wargo_config, &workspace_root)?;
102    copy_files(entries, &wargo_config, &workspace_root, &dest_dir)?;
103
104    let (artifacts, exit_code) = exec_cargo_command(&dest_dir, &workspace_root, args)?;
105    copy_artifacts(&dest_dir, &workspace_root, artifacts)?;
106
107    if let Some(code) = exit_code
108        && code != 0
109    {
110        std::process::exit(code);
111    }
112
113    Ok(())
114}
115
116// TODO: add WSL distro configuration and use it here
117// TODO: add option to use a different shell (e.g. zsh)
118#[cfg_attr(target_os = "linux", allow(dead_code))]
119fn wsl2_subshell() -> GenericResult<bool> {
120    if !check::is_wsl2() {
121        let wargo_args = parse_args()[1..].join(" ");
122        let wargo_and_args = format!("wargo {wargo_args}");
123        let args = ["--shell-type", "login", "--", "bash", "-c", &wargo_and_args];
124
125        cprintln!("wargo", "WSL2 subshelling ..." => Cyan);
126        Command::new("wsl")
127            .env("WARGO_RUN", "1")
128            .args(args)
129            .spawn()?
130            .wait()?;
131        Ok(true)
132    } else {
133        Ok(false)
134    }
135}
136
137fn parse_args() -> Vec<String> {
138    if env::args().count() == 0 {
139        return Vec::new();
140    }
141    let args: Vec<String> = env::args()
142        .skip_while(|arg| match arg.split('/').next_back() {
143            Some(a) => SKIPPABLES.contains(&a),
144            None => false,
145        })
146        .collect();
147    args
148}
149
150fn get_wargo_config<P>(workspace_root: &P) -> GenericResult<WargoConfig>
151where
152    P: AsRef<Path>,
153{
154    let wargo_config = workspace_root.as_ref().join("Wargo.toml");
155
156    let wargo_config: WargoConfig = if wargo_config.exists() {
157        let wargo_config = fs::read_to_string(wargo_config)?;
158        toml::from_str(&wargo_config)?
159    } else {
160        WargoConfig::default()
161    };
162
163    Ok(wargo_config)
164}
165
166fn get_destination_dir<P>(wargo_config: &WargoConfig, workspace_root: &P) -> PathBuf
167where
168    P: AsRef<Path>,
169{
170    let project_dir = get_project_dir(wargo_config, &workspace_root);
171
172    let dest = if let Some(dir) = &wargo_config.dest_base_dir {
173        paths::untilde(dir)
174    } else {
175        // TODO(maybe): handle rare case of None (if no home dir can be determined)
176        let home = dirs::home_dir().unwrap();
177        home.join("tmp")
178    }
179    .join(project_dir);
180
181    paths::normalize_path(&dest)
182}
183
184fn get_project_dir<'a, P>(wargo_config: &'a WargoConfig, workspace_root: &'a P) -> &'a OsStr
185where
186    P: AsRef<Path>,
187{
188    (if let Some(dir) = &wargo_config.project_dir {
189        OsStr::new(dir)
190    } else {
191        workspace_root.as_ref().iter().next_back().unwrap()
192    }) as _
193}
194
195fn collect_entries<P>(
196    wargo_config: &mut WargoConfig,
197    workspace_root: &P,
198) -> GenericResult<Vec<DirEntry>>
199where
200    P: AsRef<Path>,
201{
202    let mut patterns = vec!["**"];
203
204    // migration phase (v0.2) - remove ignore_* blocks and de-optionize with v0.4 or later
205
206    if let Some(include_git) = wargo_config.include_git {
207        if !include_git {
208            patterns.push("!.git");
209        } else {
210            wargo_config.clean_git = true;
211        }
212    } else if let Some(ignore_git) = wargo_config.ignore_git {
213        if ignore_git {
214            patterns.push("!.git");
215        } else {
216            wargo_config.clean_git = true;
217        }
218    } else {
219        // default if no option was provided
220        patterns.push("!.git");
221    }
222
223    if let Some(include_target) = wargo_config.include_target {
224        if !include_target {
225            patterns.push("!target");
226        }
227    } else if let Some(ignore_target) = wargo_config.ignore_target {
228        if ignore_target {
229            patterns.push("!target");
230        }
231    } else {
232        // default if no option was provided
233        patterns.push("!target");
234    }
235
236    let entries: Vec<DirEntry> =
237        globwalk::GlobWalkerBuilder::from_patterns(workspace_root, &patterns)
238            .contents_first(false)
239            .build()?
240            .filter_map(Result::ok)
241            .collect();
242    Ok(entries)
243}
244
245fn copy_files<P>(
246    entries: Vec<DirEntry>,
247    wargo_config: &WargoConfig,
248    workspace_root: &P,
249    dest_dir: &P,
250) -> NullResult
251where
252    P: AsRef<Path>,
253{
254    if wargo_config.clean && dest_dir.as_ref().exists() {
255        fs::remove_dir_all(dest_dir).context("dest_dir cleaning failed")?;
256    }
257
258    fs::create_dir_all(dest_dir).context("dest_dir creation failed")?;
259
260    let git_dir = &dest_dir.as_ref().join(".git");
261    if wargo_config.clean_git && git_dir.exists() {
262        fs::remove_dir_all(git_dir).context("dest_dir/.git cleaning failed")?;
263    }
264
265    let bar = progress::bar(entries.len() as u64);
266    for entry in bar.wrap_iter(entries.iter()) {
267        let is_dir = entry.file_type().is_dir();
268        let src_path = entry.path();
269        let prj_path = src_path.strip_prefix(workspace_root)?;
270        let dst_path = &dest_dir.as_ref().to_path_buf().join(prj_path);
271
272        let metadata = entry.metadata()?;
273        let mtime = FileTime::from_last_modification_time(&metadata);
274        let atime = FileTime::from_last_access_time(&metadata);
275
276        if is_dir {
277            fs::create_dir_all(dst_path).context("Directory creation failed")?;
278        } else {
279            // TODO(maybe): should skip if file is unchanged;
280            // OTOH it would mean more FS calls/checks
281            fs::copy(src_path, dst_path).with_context(|| {
282                format!(
283                    "Copying failed: {} -> {}",
284                    &src_path.display(),
285                    &dst_path.display()
286                )
287            })?;
288        }
289
290        set_symlink_file_times(dst_path, atime, mtime).with_context(|| {
291            format!("Setting file timestamps failed for {}", &dst_path.display())
292        })?;
293    }
294    bar.finish_with_message("Files copied");
295    Ok(())
296}
297
298fn exec_cargo_command<P>(
299    dest_dir: &P,
300    workspace_root: &P,
301    args: Vec<String>,
302) -> GenericResult<(Vec<PathBuf>, Option<i32>)>
303where
304    P: AsRef<Path>,
305{
306    // jump into the same relative place as it was called on the source side
307    let ws_rel_location = env::current_dir()?
308        .canonicalize()?
309        .strip_prefix(workspace_root)?
310        .to_path_buf();
311    let exec_dest = dest_dir.as_ref().join(ws_rel_location).canonicalize()?;
312
313    let mut files: Vec<PathBuf> = Vec::new();
314    let mut exit_code = None;
315
316    let mut cargo_args = args;
317    if let Some(arg) = cargo_args.first() {
318        // special case: cargo build -> use JSON output
319        // so we can retrieve and parse the compilation artifacts
320        if ["b", "build"].contains(&arg.as_str()) {
321            cargo_args.insert(1, "--message-format=json-render-diagnostics".into());
322
323            let mut cmd = Command::new("cargo")
324                .args(cargo_args)
325                .current_dir(&exec_dest)
326                .stdout(Stdio::piped())
327                .spawn()?;
328
329            let reader = std::io::BufReader::new(cmd.stdout.take().expect("no stdout captured"));
330            for message in Message::parse_stream(reader).flatten() {
331                if let Message::CompilerArtifact(artifact) = message
332                    && [
333                        TargetKind::Bin,
334                        TargetKind::DyLib,
335                        TargetKind::CDyLib,
336                        TargetKind::StaticLib,
337                    ]
338                    .contains(&artifact.target.kind[0])
339                {
340                    for filename in artifact.filenames {
341                        files.push(filename.into_std_path_buf())
342                    }
343                }
344            }
345            let status = cmd.wait()?;
346            exit_code = status.code();
347        } else {
348            let mut cmd = Command::new("cargo")
349                .args(cargo_args)
350                .current_dir(&exec_dest)
351                .spawn()?;
352            let status = cmd.wait()?;
353            exit_code = status.code();
354        }
355    };
356    Ok((files, exit_code))
357}
358
359fn copy_artifacts<P>(dest_dir: &P, workspace_root: &P, artifacts: Vec<PathBuf>) -> NullResult
360where
361    P: AsRef<Path>,
362{
363    if !artifacts.is_empty() {
364        for artifact in artifacts {
365            let rel_artifact = artifact.strip_prefix(dest_dir)?;
366            let origin_location = &workspace_root.as_ref().join(rel_artifact);
367
368            if let Some(parent) = origin_location.parent() {
369                fs::create_dir_all(parent)?;
370                fs::copy(artifact, origin_location)?;
371                cprintln!(
372                    "Copied",
373                    format!("compile artifact to:\n{}", origin_location.display()) => Green
374                );
375            }
376        }
377    };
378    Ok(())
379}