Skip to main content

command_stream/commands/
fy.rs

1//! Virtual `$fy` command: the CLI delegates all rendering to typed-link rules.
2use crate::commands::CommandContext;
3use crate::fy::{
4    cli::{parse_arguments, USAGE},
5    translate_shell_to_mjs, translate_source, TranslateOptions,
6};
7use crate::utils::{CommandResult, VirtualUtils};
8use std::collections::BTreeSet;
9use std::fs::{self, OpenOptions};
10use std::io::Write;
11
12/// Execute source migration, refusing to overwrite an existing output file.
13pub async fn fy(ctx: CommandContext) -> CommandResult {
14    let mut parsed = match parse_arguments(&ctx.args) {
15        Ok(parsed) => parsed,
16        Err(error) => return CommandResult::error(format!("$fy: translation failed: {error}\n")),
17    };
18    if parsed.help {
19        return CommandResult::success(USAGE);
20    }
21    let input = parsed.positional.first();
22    if let Some(path) = input.filter(|path| path.as_str() != "-") {
23        parsed.options.name = std::path::Path::new(path)
24            .file_name()
25            .unwrap_or_default()
26            .to_string_lossy()
27            .into_owned();
28    }
29    let source = if input.is_none_or(|path| path == "-") {
30        match ctx.stdin.as_deref().filter(|value| !value.is_empty()) {
31            Some(source) => source.to_owned(),
32            None => return CommandResult::error(USAGE),
33        }
34    } else {
35        let path = input.unwrap();
36        match fs::read_to_string(VirtualUtils::resolve_path(path, Some(&ctx.get_cwd()))) {
37            Ok(source) => source,
38            Err(error) => {
39                return CommandResult::error(format!("$fy: cannot read '{path}': {error}\n"))
40            }
41        }
42    };
43    let translated = if parsed.structural {
44        translate_shell_to_mjs(
45            &source,
46            &TranslateOptions {
47                shebang: parsed.options.shebang,
48                module_name: parsed.options.module_name,
49            },
50        )
51    } else {
52        match translate_source(&source, &parsed.options) {
53            Ok(result) => result,
54            Err(error) => {
55                return CommandResult::error(format!("$fy: translation failed: {error}\n"))
56            }
57        }
58    };
59    let warnings: BTreeSet<_> = translated.diagnostics.iter().collect();
60    let stderr: String = warnings
61        .into_iter()
62        .map(|message| format!("$fy: warning: {message}\n"))
63        .collect();
64    let Some(output) = parsed.positional.get(1) else {
65        return CommandResult::new(translated.code, stderr, 0);
66    };
67    let mut options = OpenOptions::new();
68    options.write(true).create_new(true);
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::OpenOptionsExt;
72        options.mode(0o755);
73    }
74    let write = || -> std::io::Result<()> {
75        options
76            .open(VirtualUtils::resolve_path(output, Some(&ctx.get_cwd())))?
77            .write_all(translated.code.as_bytes())
78    };
79    if let Err(error) = write() {
80        return CommandResult::error(format!("{stderr}$fy: cannot write '{output}': {error}\n"));
81    }
82    CommandResult::new(format!("Translated to '{output}'\n"), stderr, 0)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::io::Write;
89    use tempfile::NamedTempFile;
90
91    #[tokio::test]
92    async fn translates_a_file_argument() {
93        let mut temp = NamedTempFile::new().unwrap();
94        writeln!(temp, "ls -la").unwrap();
95
96        let ctx = CommandContext::new(vec![
97            "--structural".into(),
98            temp.path().to_string_lossy().to_string(),
99        ]);
100        let result = fy(ctx).await;
101
102        assert!(result.is_success());
103        assert!(result.stdout.contains("await $`ls -la`;"));
104        assert!(result.stdout.starts_with("#!/usr/bin/env node\n"));
105    }
106
107    #[tokio::test]
108    async fn translates_stdin() {
109        let mut ctx = CommandContext::new(vec!["--structural".into()]);
110        ctx.stdin = Some("echo hi\n".to_string());
111        let result = fy(ctx).await;
112
113        assert!(result.is_success());
114        assert!(result.stdout.contains("await $`echo hi`;"));
115    }
116
117    #[tokio::test]
118    async fn a_file_argument_wins_over_inherited_stdin() {
119        let mut temp = NamedTempFile::new().unwrap();
120        writeln!(temp, "pwd").unwrap();
121
122        let mut ctx = CommandContext::new(vec![
123            "--structural".into(),
124            temp.path().to_string_lossy().to_string(),
125        ]);
126        ctx.stdin = Some("echo from-stdin\n".to_string());
127        let result = fy(ctx).await;
128
129        assert!(result.stdout.contains("await $`pwd`;"));
130        assert!(!result.stdout.contains("from-stdin"));
131    }
132
133    #[tokio::test]
134    async fn writes_to_an_output_file() {
135        let mut input = NamedTempFile::new().unwrap();
136        writeln!(input, "echo hi").unwrap();
137        let output_dir = tempfile::tempdir().unwrap();
138        let output = output_dir.path().join("out.mjs");
139
140        let ctx = CommandContext::new(vec![
141            "--structural".into(),
142            input.path().to_string_lossy().to_string(),
143            output.as_path().to_string_lossy().to_string(),
144        ]);
145        let result = fy(ctx).await;
146
147        assert!(result.is_success());
148        assert!(result.stdout.starts_with("Translated to '"));
149        let written = fs::read_to_string(output.as_path()).unwrap();
150        assert!(written.contains("await $`echo hi`;"));
151    }
152
153    #[tokio::test]
154    async fn omits_the_shebang_on_request() {
155        let mut temp = NamedTempFile::new().unwrap();
156        writeln!(temp, "ls").unwrap();
157
158        let ctx = CommandContext::new(vec![
159            temp.path().to_string_lossy().to_string(),
160            "--no-shebang".to_string(),
161        ]);
162        let result = fy(ctx).await;
163
164        assert!(!result.stdout.contains("#!/usr/bin/env node"));
165    }
166
167    #[tokio::test]
168    async fn reports_usage_without_input() {
169        let result = fy(CommandContext::new(vec![])).await;
170        assert_eq!(result.code, 1);
171        assert!(result.stderr.contains("Usage:"));
172
173        let help = fy(CommandContext::new(vec!["--help".to_string()])).await;
174        assert!(help.is_success());
175        assert!(help.stdout.contains("Usage:"));
176    }
177
178    #[tokio::test]
179    async fn reports_an_unreadable_file() {
180        let ctx = CommandContext::new(vec!["/nonexistent/script.sh".to_string()]);
181        let result = fy(ctx).await;
182
183        assert_eq!(result.code, 1);
184        assert!(result.stderr.starts_with("$fy: cannot read "));
185    }
186}