Skip to main content

loonfs_cli/
lib.rs

1//! LoonFS command-line entrypoint.
2//!
3//! The CLI supports embedded profiles that talk directly to object storage and
4//! remote profiles that talk to a LoonFS server. It keeps command output stable
5//! for humans and scripts.
6
7// The embedded runtime's composed async graph keeps growing; even with
8// the boxed entrypoint, rustc's future-layout query depth needs headroom
9// here. This is the raise rustc itself prescribes.
10#![recursion_limit = "256"]
11#![allow(
12    clippy::result_large_err,
13    reason = "CLI errors expose all structured backend fields"
14)]
15mod args;
16mod backend;
17mod backend_error;
18mod commands;
19mod config;
20mod error;
21mod payload;
22mod profiles;
23mod progress;
24mod prompt;
25mod render;
26mod resolve;
27mod uploads;
28
29use clap::Parser;
30use std::process::ExitCode;
31
32/// Exit status for a command line the parser rejected, which is clap's own.
33///
34/// It stays distinct from the failure status a command that actually ran
35/// reports, so a script can tell "this command never started" from "this
36/// command started and failed" without reading the message.
37const USAGE_EXIT_CODE: u8 = 2;
38
39pub async fn main() -> ExitCode {
40    let cli = match args::Cli::try_parse() {
41        Ok(cli) => cli,
42        Err(error) => return render_parse_failure(&error),
43    };
44    if let Err(error) = args::validate_cli(&cli) {
45        return render_parse_failure(&error);
46    }
47    let runtime = args::RuntimeBehavior::detect(&cli);
48
49    // Boxing keeps the public entrypoint's future shallow for downstream binaries.
50    let result = Box::pin(commands::run(cli, runtime)).await;
51    match result {
52        Ok(output) => match render::render_success(&output, runtime.json) {
53            // A recursive transfer renders its per-item outcomes as success
54            // data but still exits nonzero when any item failed.
55            Ok(()) if output.data.reports_failures() => ExitCode::FAILURE,
56            Ok(()) => ExitCode::SUCCESS,
57            Err(err) => {
58                let failure = commands::CommandFailure {
59                    kind: output.kind,
60                    profile: output.profile.clone(),
61                    mode: output.mode,
62                    error: Box::new(error::CliError::io(err)),
63                };
64                let _ = render::render_error(&failure, runtime.json);
65                ExitCode::FAILURE
66            }
67        },
68        Err(failure) => {
69            let _ = render::render_error(&failure, runtime.json);
70            ExitCode::FAILURE
71        }
72    }
73}
74
75/// Renders a command line clap rejected, in whichever form the caller asked
76/// for.
77///
78/// `--json` is one of the things clap failed to parse, so whether it was
79/// asked for has to be read off the raw arguments. A caller who asked for
80/// JSON gets the same envelope a runtime failure produces, and every caller
81/// keeps clap's exit status: a parse failure is not a command that ran.
82///
83/// `--help` and `--version` arrive here as errors too, and are not
84/// failures: clap renders them on stdout and exits zero.
85fn render_parse_failure(error: &clap::Error) -> ExitCode {
86    if !error.use_stderr() {
87        error.print().ok();
88        return ExitCode::SUCCESS;
89    }
90    if !json_requested(std::env::args_os()) {
91        error.print().ok();
92        return ExitCode::from(USAGE_EXIT_CODE);
93    }
94    let failure = parse_failure_error(error);
95    let _ = render::render_parse_error(&failure);
96    ExitCode::from(USAGE_EXIT_CODE)
97}
98
99fn parse_failure_error(error: &clap::Error) -> error::CliError {
100    let failure = error::CliError::invalid_usage(error.render().to_string());
101    match parse_error_param(error) {
102        Some(param) => failure.with_param(param),
103        None => failure,
104    }
105}
106
107fn parse_error_param(error: &clap::Error) -> Option<String> {
108    use clap::error::{ContextKind, ContextValue};
109
110    let value = error.get(ContextKind::InvalidArg)?;
111    let rendered = match value {
112        ContextValue::String(value) => value.clone(),
113        ContextValue::Strings(values) => values.first()?.clone(),
114        ContextValue::StyledStr(value) => value.to_string(),
115        ContextValue::StyledStrs(values) => values.first()?.to_string(),
116        _ => return None,
117    };
118    let token = rendered.split_whitespace().next()?;
119    Some(
120        token
121            .trim_matches(|character| matches!(character, '`' | '\'' | '[' | ']'))
122            .to_owned(),
123    )
124}
125
126/// Whether the raw arguments asked for `--json`, scanned the way clap would
127/// have: a bare `--` ends option parsing, so a `--json` after it is a value,
128/// not this flag.
129fn json_requested(arguments: impl IntoIterator<Item = std::ffi::OsString>) -> bool {
130    for argument in arguments {
131        if argument == "--" {
132            return false;
133        }
134        if argument == "--json" {
135            return true;
136        }
137    }
138    false
139}