Skip to main content

aoc_runtime/
app.rs

1//! Executing a resolved [`Plan`].
2//!
3//! Every outside dependency is injected, so each handler can be driven end to
4//! end in a test with a fake runner, a fake client and a temporary directory.
5
6mod 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
20/// The file a solution reads its puzzle input from.
21pub const INPUT_FILE_NAME: &str = "input.txt";
22
23/// Everything a command handler needs.
24pub struct App<'a> {
25    /// Validated configuration.
26    pub config: &'a Config,
27    /// How child processes are executed.
28    pub runner: &'a dyn CommandRunner,
29    /// The Advent of Code client, absent when no session cookie is configured.
30    pub client: Option<&'a dyn AocClient>,
31    /// Where accepted answers are remembered.
32    pub cache: &'a dyn AnswerCache,
33    /// Where downloaded puzzle inputs are kept. Concrete rather than a trait,
34    /// unlike its neighbours: linking a project at a cached file is what it
35    /// does, and there is no linking without a filesystem to do it on.
36    pub inputs: &'a InputStore,
37    /// Where output goes.
38    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    /// Carries out a plan.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error`] if the work cannot be completed.
56    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    /// Makes sure a solution has its `input.txt` to read.
94    ///
95    /// The input itself lives in the state directory and the file beside the
96    /// project is a link to it, so a day is downloaded once and never again:
97    /// a project that lost its `input.txt`, or never had one, is linked at the
98    /// copy already on disk instead of costing another request.
99    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        // Assembled component by component, and quoted as a single-quoted YAML
205        // scalar: a Windows path carries backslashes, which a double-quoted
206        // scalar would read as escape sequences.
207        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}