command_stream/commands/
fy.rs1use 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
12pub 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}