Skip to main content

clawless_core/context/
mod.rs

1//! Context for Clawless commands
2//!
3//! This module defines the [`Context`] struct, which provides information about the environment
4//! that Clawless commands are executed in, access to shared resources like loggers, and
5//! configuration settings.
6//!
7//! For information on the context that is available to commands, see the fields and methods of the
8//! [`Context`] struct as well as the types defined in this module.
9
10use bon::bon;
11use getset::{CopyGetters, Getters};
12
13pub use self::current_working_directory::CurrentWorkingDirectory;
14pub use self::error::ContextError;
15pub use self::interactivity::Interactivity;
16use crate::cancellation::Cancellation;
17use crate::output::Output;
18use crate::process::Process;
19use crate::prompt::Prompt;
20
21/// Newtype for the directory that a command runs in
22mod current_working_directory;
23/// Errors that occur when Clawless builds a [`Context`]
24mod error;
25/// Whether a user is present who can answer the application
26mod interactivity;
27
28/// Context for Clawless commands
29///
30/// This struct provides information about the environment that Clawless commands are executed in,
31/// access to shared resources, and configuration settings. It is passed to each command by the
32/// Clawless runtime when executing commands.
33///
34/// # Examples
35///
36/// ```rust,ignore
37/// #[derive(Debug, Args)]
38/// pub struct GreetArgs {
39///     name: String,
40/// }
41///
42/// #[command]
43/// pub async fn greet(args: GreetArgs, context: Context) -> CommandResult {
44///     message!("Hello, {}!", args.name);
45///     Ok(())
46/// }
47/// ```
48// r[impl context.safety.send]
49// r[impl context.safety.sync]
50// r[impl context.safety.unpin]
51#[derive(Clone, Debug, CopyGetters, Getters)]
52pub struct Context {
53    /// The working directory in which a command was called
54    // r[impl context.field.cwd]
55    #[getset(get = "pub")]
56    current_working_directory: CurrentWorkingDirectory,
57
58    /// The cancellation token for cooperative shutdown
59    // r[impl cancel.context.field]
60    // r[impl context.field.cancellation]
61    #[getset(get = "pub")]
62    cancellation: Cancellation,
63
64    /// Whether a user is present who can answer the application
65    #[getset(get_copy = "pub")]
66    interactivity: Interactivity,
67
68    /// The output handler for sending events to the presenter
69    // r[impl context.field.output]
70    #[getset(get = "pub")]
71    output: Output,
72}
73
74#[bon]
75impl Context {
76    /// Creates a new [`Context`] instance
77    ///
78    /// When `current_working_directory` is omitted, it is auto-detected from the environment.
79    /// When provided explicitly (e.g., in tests), the given value is used directly.
80    ///
81    /// When `interactivity` is omitted, the context reports [`Interactivity::NonInteractive`].
82    ///
83    /// # Errors
84    ///
85    /// Returns [`ContextError::CurrentWorkingDirectory`] if `current_working_directory` is not
86    /// provided and the current working directory cannot be determined from the environment.
87    ///
88    /// # Examples
89    ///
90    /// ```rust,ignore
91    /// // Production: CWD auto-detected
92    /// let context = Context::builder()
93    ///     .output(output)
94    ///     .build()?;
95    ///
96    /// // Tests: explicit CWD
97    /// let context = Context::builder()
98    ///     .current_working_directory(tmp.path())
99    ///     .output(output)
100    ///     .build()?;
101    /// ```
102    // r[impl context.new]
103    // r[impl context.new.error]
104    // r[impl cancel.context.default]
105    // r[impl cancel.context.injectable]
106    #[builder]
107    pub fn new(
108        #[builder(into)] current_working_directory: Option<CurrentWorkingDirectory>,
109        #[builder(default)] cancellation: Cancellation,
110        #[builder(default)] interactivity: Interactivity,
111        output: Output,
112    ) -> Result<Self, ContextError> {
113        let current_working_directory = match current_working_directory {
114            Some(cwd) => cwd,
115            None => CurrentWorkingDirectory::try_from_env()?,
116        };
117
118        Ok(Self {
119            current_working_directory,
120            cancellation,
121            interactivity,
122            output,
123        })
124    }
125}
126
127impl Context {
128    /// Returns the interface that runs external programs
129    ///
130    /// The returned [`Process`] reports every run through the output of this context and stops a
131    /// run when the cancellation token of this context is cancelled. A command therefore gets live
132    /// output and cooperative shutdown without wiring either of them itself.
133    ///
134    /// Each call builds a handle over the same channel and the same token, so a command can call
135    /// this method wherever it runs a program instead of holding the handle.
136    ///
137    /// The handle takes the default grace period, which is the time a program gets to end itself
138    /// before Clawless kills it. A command that drives a program needing longer builds its own
139    /// handle with [`Process::builder`].
140    ///
141    /// # Examples
142    ///
143    /// ```rust,ignore
144    /// #[command]
145    /// pub async fn build(args: BuildArgs, context: Context) -> CommandResult {
146    ///     context
147    ///         .process()
148    ///         .run(Invocation::new("cargo").arg("build"))
149    ///         .await?
150    ///         .require_success()?;
151    ///
152    ///     Ok(())
153    /// }
154    /// ```
155    // r[impl context.process]
156    pub fn process(&self) -> Process {
157        Process::builder()
158            .output(self.output.clone())
159            .cancellation(self.cancellation.clone())
160            .build()
161    }
162
163    /// Returns the interface that asks the user a question
164    ///
165    /// The returned [`Prompt`] uses the output, the cancellation token, and the
166    /// [`Interactivity`] of this context. Each call builds a handle over the same channel and the
167    /// same token, so a command does not need to keep the handle.
168    ///
169    /// # Examples
170    ///
171    /// ```rust,ignore
172    /// #[command]
173    /// pub async fn publish(args: PublishArgs, context: Context) -> CommandResult {
174    ///     match context.prompt().confirm("Publish the release?").await? {
175    ///         Confirmation::Yes => message!("Publishing"),
176    ///         Confirmation::No => message!("Nothing published"),
177    ///     }
178    ///
179    ///     Ok(())
180    /// }
181    /// ```
182    pub fn prompt(&self) -> Prompt {
183        Prompt::builder()
184            .output(self.output.clone())
185            .cancellation(self.cancellation.clone())
186            .interactivity(self.interactivity)
187            .build()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    // An assertion in a test panics by design. A `# Panics` section on every test
194    // would repeat that and give the reader no information.
195    #![allow(clippy::missing_panics_doc)]
196
197    use std::path::Path;
198
199    use super::*;
200    use crate::event::Event;
201    use crate::event::event_channel;
202    use crate::process::Invocation;
203
204    fn test_output() -> Output {
205        let (sender, _receiver) = event_channel();
206        Output::new(sender)
207    }
208
209    // r[verify context.field.cancellation]
210    // r[verify cancel.context.injectable]
211    // r[verify cancel.context.field]
212    #[test]
213    fn new_with_cancellation_uses_provided_token() {
214        let cancellation = Cancellation::new();
215        cancellation.cancel();
216
217        let context = Context::builder()
218            .current_working_directory(Path::new("/tmp"))
219            .cancellation(cancellation)
220            .output(test_output())
221            .build()
222            .expect("should create context");
223
224        assert!(context.cancellation().is_cancelled());
225    }
226
227    // r[verify context.field.cwd]
228    #[test]
229    fn new_with_cwd_uses_provided_value() {
230        let context = Context::builder()
231            .current_working_directory(Path::new("/tmp"))
232            .output(test_output())
233            .build()
234            .expect("should create context");
235
236        assert_eq!(context.current_working_directory().get(), Path::new("/tmp"));
237    }
238
239    // r[verify context.new]
240    // r[verify context.field.output]
241    #[test]
242    fn new_with_defaults_detects_cwd() {
243        let expected = std::env::current_dir().expect("should get current dir");
244
245        let context = Context::builder()
246            .output(test_output())
247            .build()
248            .expect("should create context");
249
250        assert_eq!(context.current_working_directory().get(), expected);
251    }
252
253    // r[verify cancel.context.default]
254    #[test]
255    fn new_with_defaults_has_uncancelled_token() {
256        let context = Context::builder()
257            .current_working_directory(Path::new("/tmp"))
258            .output(test_output())
259            .build()
260            .expect("should create context");
261
262        assert!(!context.cancellation().is_cancelled());
263    }
264
265    #[test]
266    fn new_with_defaults_is_non_interactive() {
267        let context = Context::builder()
268            .current_working_directory(Path::new("/tmp"))
269            .output(test_output())
270            .build()
271            .expect("should create context");
272
273        assert_eq!(context.interactivity(), Interactivity::NonInteractive);
274    }
275
276    #[test]
277    fn new_with_interactivity_uses_provided_value() {
278        let context = Context::builder()
279            .current_working_directory(Path::new("/tmp"))
280            .interactivity(Interactivity::Interactive)
281            .output(test_output())
282            .build()
283            .expect("should create context");
284
285        assert_eq!(context.interactivity(), Interactivity::Interactive);
286    }
287
288    // r[verify context.process]
289    #[cfg(unix)]
290    #[tokio::test]
291    async fn process_reports_through_the_output_of_the_context() {
292        let (sender, mut receiver) = event_channel();
293        let context = Context::builder()
294            .output(Output::new(sender))
295            .build()
296            .expect("should build");
297        let invocation = Invocation::new("sh").arg("-c").arg("exit 0");
298
299        context
300            .process()
301            .run(invocation.clone())
302            .await
303            .expect("should run");
304
305        assert_eq!(
306            receiver.recv().await.map(|event| match event {
307                Event::Process(event) => event.to_string(),
308                Event::Message(text) => text,
309                Event::Detail(text) => text,
310                Event::Artifact(artifact) => artifact.to_string(),
311                Event::Prompt(request) => format!("{request:?}"),
312            }),
313            Some(format!("$ {invocation}"))
314        );
315    }
316
317    #[test]
318    fn prompt_carries_the_interactivity_of_the_context() {
319        let context = Context::builder()
320            .current_working_directory(Path::new("/tmp"))
321            .interactivity(Interactivity::Interactive)
322            .output(test_output())
323            .build()
324            .expect("should create context");
325
326        let prompt = context.prompt();
327
328        assert_eq!(prompt.interactivity(), Interactivity::Interactive);
329    }
330
331    #[tokio::test]
332    async fn prompt_observes_the_cancellation_of_the_context() {
333        let cancellation = Cancellation::new();
334        let (sender, _receiver) = event_channel();
335        let context = Context::builder()
336            .current_working_directory(Path::new("/tmp"))
337            .cancellation(cancellation.clone())
338            .interactivity(Interactivity::Interactive)
339            .output(Output::new(sender))
340            .build()
341            .expect("should create context");
342        cancellation.cancel();
343
344        let error = context
345            .prompt()
346            .confirm("Release?")
347            .await
348            .expect_err("should fail");
349
350        assert_eq!(
351            error.to_string(),
352            "the prompt `Release?` was cancelled before the user answered"
353        );
354    }
355
356    #[tokio::test]
357    async fn prompt_sends_through_the_output_of_the_context() {
358        let (sender, mut receiver) = event_channel();
359        let context = Context::builder()
360            .current_working_directory(Path::new("/tmp"))
361            .interactivity(Interactivity::Interactive)
362            .output(Output::new(sender))
363            .build()
364            .expect("should create context");
365        let prompt = context.prompt();
366        let command = tokio::spawn(async move { prompt.confirm("Release?").await });
367
368        let event = receiver.recv().await;
369        command.abort();
370
371        assert_eq!(
372            event.map(|event| match event {
373                Event::Prompt(request) => request.question().to_owned(),
374                Event::Process(event) => event.to_string(),
375                Event::Message(text) => text,
376                Event::Detail(text) => text,
377                Event::Artifact(artifact) => artifact.to_string(),
378            }),
379            Some("Release?".to_owned())
380        );
381    }
382
383    // r[verify context.safety.send]
384    #[test]
385    fn trait_send() {
386        fn assert_send<T: Send>() {}
387        assert_send::<Context>();
388    }
389
390    // r[verify context.safety.sync]
391    #[test]
392    fn trait_sync() {
393        fn assert_sync<T: Sync>() {}
394        assert_sync::<Context>();
395    }
396
397    // r[verify context.safety.unpin]
398    #[test]
399    fn trait_unpin() {
400        fn assert_unpin<T: Unpin>() {}
401        assert_unpin::<Context>();
402    }
403}