1mod init;
7mod run;
8
9use crate::{
10 aoc::{AocClient, cache::AnswerCache, input::InputStore},
11 config::Config,
12 error::Error,
13 process::{CommandRunner, CommandSpec},
14 puzzle::Puzzle,
15 report::Reporter,
16 resolve::Plan,
17};
18use std::path::Path;
19
20pub const INPUT_FILE_NAME: &str = "input.txt";
22
23pub struct App<'a> {
25 pub config: &'a Config,
27 pub runner: &'a dyn CommandRunner,
29 pub client: Option<&'a dyn AocClient>,
31 pub cache: &'a dyn AnswerCache,
33 pub inputs: &'a InputStore,
37 pub reporter: &'a mut dyn Reporter,
39}
40
41impl std::fmt::Debug for App<'_> {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("App")
44 .field("config", &self.config)
45 .field("has_client", &self.client.is_some())
46 .finish_non_exhaustive()
47 }
48}
49
50impl App<'_> {
51 pub fn execute(&mut self, plan: Plan) -> Result<(), Error> {
57 match plan {
58 Plan::Run {
59 puzzle,
60 language,
61 project,
62 submit,
63 } => self.run(puzzle, language, &project, submit),
64 Plan::Init {
65 puzzle,
66 language,
67 project,
68 } => self.init(puzzle, language, &project),
69 Plan::Path { project } => {
70 self.reporter.data(&project.to_string_lossy());
71 Ok(())
72 }
73 Plan::Code { project } => self.open_editor(&project),
74 Plan::Url { puzzle } => {
75 self.reporter.data(&puzzle.url());
76 Ok(())
77 }
78 }
79 }
80
81 fn open_editor(&mut self, project: &Path) -> Result<(), Error> {
82 if !project.exists() {
83 return Err(Error::ProjectMissing {
84 path: project.to_path_buf(),
85 });
86 }
87
88 let editor = CommandSpec::new(&self.config.editor).arg(project);
89 self.runner.spawn_detached(&editor)?;
90 Ok(())
91 }
92
93 fn ensure_input(&mut self, puzzle: Puzzle, project: &Path) {
100 let Some(parent) = project.parent() else {
101 self.reporter
102 .warn("project path has no parent directory, skipping input download");
103 return;
104 };
105
106 let input = parent.join(INPUT_FILE_NAME);
107 if input.exists() {
108 return;
109 }
110
111 if !self.inputs.holds(puzzle) {
112 let Some(client) = self.client else {
113 self.reporter.warn(&format!(
114 "no session cookie configured, so {} was not downloaded",
115 input.display()
116 ));
117 return;
118 };
119
120 let downloaded = client
121 .fetch_input(puzzle)
122 .map_err(Error::from)
123 .and_then(|text| self.inputs.store(puzzle, &text));
124
125 if let Err(error) = downloaded {
126 self.reporter
127 .warn(&format!("could not download puzzle input: {error}"));
128 return;
129 }
130 }
131
132 if let Err(error) = self.inputs.link(puzzle, &input) {
133 self.reporter
134 .warn(&format!("could not link puzzle input: {error}"));
135 }
136 }
137}
138
139#[cfg(test)]
140pub(crate) mod testing {
141 use super::App;
142 use crate::{
143 aoc::{cache::memory::MemoryCache, fake::FakeClient, input::InputStore},
144 config::Config,
145 env::Env,
146 process::fake::FakeRunner,
147 report::recording::RecordingReporter,
148 };
149 use std::path::Path;
150
151 pub(crate) struct Harness {
152 pub(crate) config: Config,
153 pub(crate) runner: FakeRunner,
154 pub(crate) client: Option<FakeClient>,
155 pub(crate) cache: MemoryCache,
156 pub(crate) inputs: InputStore,
157 pub(crate) reporter: RecordingReporter,
158 }
159
160 impl Harness {
161 pub(crate) fn new(root: &Path) -> Self {
162 Self {
163 config: config_for(root),
164 runner: FakeRunner::new(),
165 client: None,
166 cache: MemoryCache::new(),
167 inputs: InputStore::new(root.join("state")),
168 reporter: RecordingReporter::new(),
169 }
170 }
171
172 pub(crate) fn with_client(mut self, client: FakeClient) -> Self {
173 self.client = Some(client);
174 self
175 }
176
177 pub(crate) fn with_cache(mut self, cache: MemoryCache) -> Self {
178 self.cache = cache;
179 self
180 }
181
182 pub(crate) fn app(&mut self) -> App<'_> {
183 App {
184 config: &self.config,
185 runner: &self.runner,
186 client: self.client.as_ref().map(|client| client as _),
187 cache: &self.cache,
188 inputs: &self.inputs,
189 reporter: &mut self.reporter,
190 }
191 }
192 }
193
194 fn config_for(root: &Path) -> Config {
195 let env = Env {
196 home: root.to_path_buf(),
197 config_dir: root.join("config"),
198 config_file: root.join("config").join("config.yaml"),
199 state_dir: root.join("state"),
200 cwd: root.to_path_buf(),
201 session_cookie: None,
202 };
203
204 let template = root
208 .join("{{year}}")
209 .join("day{{pad day}}")
210 .join("{{language}}");
211
212 let yaml = format!(
213 "template_path: '{}'",
214 template.to_string_lossy().replace('\'', "''")
215 );
216
217 let (config, _) = Config::from_yaml(&yaml, &env).expect("fixture config should load");
218 config
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::{testing::Harness, *};
225 use crate::{
226 puzzle::{Day, Year},
227 report::Event,
228 };
229 use std::fs;
230
231 fn puzzle() -> Puzzle {
232 Puzzle::new(
233 Year::new(2024).expect("valid year"),
234 Day::new(7).expect("valid day"),
235 )
236 .expect("2024 has a day 7")
237 }
238
239 #[test]
240 fn path_prints_the_project_directory_to_stdout() {
241 let root = tempfile::tempdir().expect("temp dir");
242 let mut harness = Harness::new(root.path());
243 let project = root.path().join("2024").join("day07").join("rust");
244
245 harness
246 .app()
247 .execute(Plan::Path {
248 project: project.clone(),
249 })
250 .expect("path should succeed");
251
252 assert_eq!(
253 harness.reporter.events,
254 [Event::Data(project.to_string_lossy().into_owned())]
255 );
256 }
257
258 #[test]
259 fn url_prints_the_puzzle_link_to_stdout() {
260 let root = tempfile::tempdir().expect("temp dir");
261 let mut harness = Harness::new(root.path());
262
263 harness
264 .app()
265 .execute(Plan::Url { puzzle: puzzle() })
266 .expect("url should succeed");
267
268 assert_eq!(
269 harness.reporter.events,
270 [Event::Data(
271 "https://adventofcode.com/2024/day/7".to_owned()
272 )]
273 );
274 }
275
276 #[test]
277 fn code_launches_the_configured_editor_without_waiting() {
278 let root = tempfile::tempdir().expect("temp dir");
279 let project = root.path().join("2024").join("day07").join("rust");
280 fs::create_dir_all(&project).expect("create project");
281 let mut harness = Harness::new(root.path());
282
283 harness
284 .app()
285 .execute(Plan::Code {
286 project: project.clone(),
287 })
288 .expect("code should succeed");
289
290 let spawned = harness.runner.spawned();
291 assert_eq!(spawned.len(), 1);
292 assert_eq!(spawned[0].program(), "code");
293 assert_eq!(spawned[0].arguments(), [project.as_os_str()]);
294 assert!(
295 harness.runner.executed().is_empty(),
296 "the editor must not be waited on"
297 );
298 }
299
300 #[test]
301 fn code_refuses_to_open_a_project_that_does_not_exist() {
302 let root = tempfile::tempdir().expect("temp dir");
303 let mut harness = Harness::new(root.path());
304
305 let error = harness
306 .app()
307 .execute(Plan::Code {
308 project: root.path().join("2024").join("day07").join("rust"),
309 })
310 .expect_err("project does not exist");
311
312 assert!(matches!(error, Error::ProjectMissing { .. }), "{error:?}");
313 }
314}