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