clawless_core/context/
mod.rs1use 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
21mod current_working_directory;
23mod error;
25mod interactivity;
27
28#[derive(Clone, Debug, CopyGetters, Getters)]
52pub struct Context {
53 #[getset(get = "pub")]
56 current_working_directory: CurrentWorkingDirectory,
57
58 #[getset(get = "pub")]
62 cancellation: Cancellation,
63
64 #[getset(get_copy = "pub")]
66 interactivity: Interactivity,
67
68 #[getset(get = "pub")]
71 output: Output,
72}
73
74#[bon]
75impl Context {
76 #[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 pub fn process(&self) -> Process {
157 Process::builder()
158 .output(self.output.clone())
159 .cancellation(self.cancellation.clone())
160 .build()
161 }
162
163 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 #![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 #[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 #[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 #[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 #[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 #[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 #[test]
385 fn trait_send() {
386 fn assert_send<T: Send>() {}
387 assert_send::<Context>();
388 }
389
390 #[test]
392 fn trait_sync() {
393 fn assert_sync<T: Sync>() {}
394 assert_sync::<Context>();
395 }
396
397 #[test]
399 fn trait_unpin() {
400 fn assert_unpin<T: Unpin>() {}
401 assert_unpin::<Context>();
402 }
403}