1use crate::process::{CommandSpec, ProcessError};
8use clap::ValueEnum;
9use std::{
10 fmt,
11 path::{Path, PathBuf},
12 str::FromStr,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
17#[clap(rename_all = "lowercase")]
18pub enum Language {
19 Rust,
21 CSharp,
23 Java,
25 Python,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LanguageCommands {
32 pub init: Option<CommandSpec>,
34 pub build: Option<CommandSpec>,
36 pub run: CommandSpec,
38 pub run_fallback: Option<CommandSpec>,
40}
41
42impl Language {
43 pub const ALL: &'static [Self] = &[Self::Rust, Self::CSharp, Self::Java, Self::Python];
45
46 #[must_use]
49 pub const fn name(self) -> &'static str {
50 match self {
51 Self::Rust => "rust",
52 Self::CSharp => "csharp",
53 Self::Java => "java",
54 Self::Python => "python",
55 }
56 }
57
58 #[must_use]
60 pub const fn base_extension(self) -> &'static str {
61 match self {
62 Self::Rust => "rs",
63 Self::CSharp => "cs",
64 Self::Java => "java",
65 Self::Python => "py",
66 }
67 }
68
69 #[must_use]
72 pub fn base_file(self, config_dir: &Path) -> PathBuf {
73 config_dir.join(format!("base.{}", self.base_extension()))
74 }
75
76 #[must_use]
78 pub fn entry_file(self, project: &Path) -> PathBuf {
79 match self {
80 Self::Rust => project.join("src").join("main.rs"),
81 Self::CSharp => project.join("Program.cs"),
82 Self::Java => project.join("Main.java"),
83 Self::Python => project.join("main.py"),
84 }
85 }
86
87 pub fn commands(self, project: &Path) -> Result<LanguageCommands, ProcessError> {
95 let commands = match self {
96 Self::Rust => {
97 let manifest = project.join("Cargo.toml");
98 LanguageCommands {
99 init: Some(
100 CommandSpec::new("cargo")
101 .args(["init", "--bin"])
102 .arg(project),
103 ),
104 build: Some(
105 CommandSpec::new("cargo")
106 .args(["build", "--release", "--manifest-path"])
107 .arg(&manifest),
108 ),
109 run: CommandSpec::new("cargo")
110 .args(["run", "--release", "--quiet", "--manifest-path"])
111 .arg(&manifest),
112 run_fallback: None,
113 }
114 }
115 Self::CSharp => {
116 let name = project
117 .file_name()
118 .ok_or_else(|| ProcessError::NoDirectoryName {
119 path: project.to_path_buf(),
120 })?;
121
122 LanguageCommands {
123 init: Some(
124 CommandSpec::new("dotnet")
125 .args(["new", "console", "--name"])
126 .arg(name)
127 .arg("--output")
128 .arg(project),
129 ),
130 build: Some(
131 CommandSpec::new("dotnet")
132 .args(["build", "-c", "Release", "--nologo", "-v", "q"])
133 .arg(project),
134 ),
135 run: CommandSpec::new("dotnet")
136 .args(["run", "-c", "Release", "--no-build", "--project"])
137 .arg(project),
138 run_fallback: None,
139 }
140 }
141 Self::Java => LanguageCommands {
142 init: None,
143 build: Some(CommandSpec::new("javac").arg(self.entry_file(project))),
144 run: CommandSpec::new("java").arg("-cp").arg(project).arg("Main"),
145 run_fallback: None,
146 },
147 Self::Python => LanguageCommands {
148 init: None,
149 build: None,
150 run: CommandSpec::new("python3").arg(self.entry_file(project)),
151 run_fallback: Some(CommandSpec::new("python").arg(self.entry_file(project))),
152 },
153 };
154
155 Ok(commands.with_working_dir(project))
156 }
157}
158
159impl LanguageCommands {
160 fn with_working_dir(self, project: &Path) -> Self {
161 Self {
162 init: self.init.map(|spec| spec.current_dir(project)),
163 build: self.build.map(|spec| spec.current_dir(project)),
164 run: self.run.current_dir(project),
165 run_fallback: self.run_fallback.map(|spec| spec.current_dir(project)),
166 }
167 }
168}
169
170impl fmt::Display for Language {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.write_str(self.name())
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178#[error("unknown language `{name}` (expected one of: rust, csharp, java, python)")]
179pub struct UnknownLanguage {
180 pub name: String,
182}
183
184impl FromStr for Language {
185 type Err = UnknownLanguage;
186
187 fn from_str(value: &str) -> Result<Self, Self::Err> {
188 Self::ALL
189 .iter()
190 .copied()
191 .find(|language| language.name() == value)
192 .ok_or_else(|| UnknownLanguage {
193 name: value.to_owned(),
194 })
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use std::ffi::OsStr;
202
203 fn project() -> PathBuf {
204 PathBuf::from("/aoc/2024/day07/rust")
205 }
206
207 fn args_of(spec: &CommandSpec) -> Vec<String> {
208 spec.arguments()
209 .iter()
210 .map(|arg| arg.to_string_lossy().into_owned())
211 .collect()
212 }
213
214 #[test]
215 fn every_variant_has_commands_and_a_unique_name() {
216 let mut names: Vec<_> = Language::ALL.iter().map(|l| l.name()).collect();
217 names.sort_unstable();
218 names.dedup();
219 assert_eq!(names.len(), Language::ALL.len());
220
221 for language in Language::ALL {
222 let commands = language
223 .commands(&project())
224 .expect("commands should build");
225 assert_eq!(commands.run.working_dir(), Some(project().as_path()));
226 }
227 }
228
229 #[test]
230 fn names_match_the_command_line_values_clap_accepts() {
231 for language in Language::ALL {
232 let value = language
233 .to_possible_value()
234 .expect("every variant is selectable");
235 assert_eq!(value.get_name(), language.name());
236 }
237 }
238
239 #[test]
240 fn parses_from_its_own_name() {
241 for language in Language::ALL {
242 assert_eq!(language.name().parse(), Ok(*language));
243 }
244 assert!("Rust".parse::<Language>().is_err());
245 assert!("c-sharp".parse::<Language>().is_err());
246 }
247
248 #[test]
249 fn rust_runs_the_optimized_build() {
250 let commands = Language::Rust
251 .commands(&project())
252 .expect("commands should build");
253
254 assert_eq!(commands.run.program(), "cargo");
255 let args = args_of(&commands.run);
256 assert!(args.contains(&"run".to_owned()), "{args:?}");
257 assert!(args.contains(&"--release".to_owned()), "{args:?}");
258 let manifest = project().join("Cargo.toml");
259 assert!(
260 args.contains(&manifest.to_string_lossy().into_owned()),
261 "{args:?}"
262 );
263 }
264
265 #[test]
266 fn csharp_names_the_project_after_its_directory() {
267 let commands = Language::CSharp
268 .commands(Path::new("/aoc/2024/day07/csharp"))
269 .expect("commands should build");
270 let init = commands.init.expect("csharp scaffolds with dotnet new");
271
272 assert_eq!(
273 args_of(&init),
274 [
275 "new",
276 "console",
277 "--name",
278 "csharp",
279 "--output",
280 "/aoc/2024/day07/csharp"
281 ]
282 );
283 }
284
285 #[test]
286 fn csharp_run_reuses_the_build_output() {
287 let commands = Language::CSharp
288 .commands(&project())
289 .expect("commands should build");
290
291 assert!(args_of(&commands.run).contains(&"--no-build".to_owned()));
292 assert!(
293 args_of(&commands.build.expect("csharp is compiled")).contains(&"Release".to_owned())
294 );
295 }
296
297 #[test]
298 fn a_project_path_without_a_directory_name_is_an_error() {
299 let error = Language::CSharp
300 .commands(Path::new("/"))
301 .expect_err("root has no directory name");
302
303 assert!(
304 matches!(error, ProcessError::NoDirectoryName { .. }),
305 "got {error:?}"
306 );
307 }
308
309 #[test]
310 fn python_is_interpreted_and_falls_back_to_python2_naming() {
311 let commands = Language::Python
312 .commands(Path::new("/aoc/2024/day07/python"))
313 .expect("commands should build");
314
315 assert!(commands.build.is_none());
316 assert!(commands.init.is_none());
317 assert_eq!(commands.run.program(), "python3");
318 assert_eq!(
319 commands.run_fallback.as_ref().map(CommandSpec::program),
320 Some(OsStr::new("python"))
321 );
322 }
323
324 #[test]
325 fn java_compiles_and_runs_the_main_class() {
326 let path = Path::new("/aoc/2024/day07/java");
327 let commands = Language::Java
328 .commands(path)
329 .expect("commands should build");
330
331 assert!(commands.init.is_none());
332 assert_eq!(
333 args_of(&commands.build.expect("java is compiled")),
334 [path.join("Main.java").to_string_lossy().into_owned()]
335 );
336 assert_eq!(
337 args_of(&commands.run),
338 ["-cp", "/aoc/2024/day07/java", "Main"]
339 );
340 }
341
342 #[test]
343 fn entry_and_base_files_follow_language_conventions() {
344 let config = Path::new("/home/u/.config/aoc");
345 let project = Path::new("/aoc/2024/day07/x");
346
347 let expected = [
348 (
349 Language::Rust,
350 "/aoc/2024/day07/x/src/main.rs",
351 "/home/u/.config/aoc/base.rs",
352 ),
353 (
354 Language::CSharp,
355 "/aoc/2024/day07/x/Program.cs",
356 "/home/u/.config/aoc/base.cs",
357 ),
358 (
359 Language::Java,
360 "/aoc/2024/day07/x/Main.java",
361 "/home/u/.config/aoc/base.java",
362 ),
363 (
364 Language::Python,
365 "/aoc/2024/day07/x/main.py",
366 "/home/u/.config/aoc/base.py",
367 ),
368 ];
369
370 for (language, entry, base) in expected {
371 assert_eq!(language.entry_file(project), Path::new(entry));
372 assert_eq!(language.base_file(config), Path::new(base));
373 }
374 }
375}