1use std::{fs, path::Path, sync::mpsc, thread, time::Duration};
4
5use serde_json::{json, Value};
6use starweaver_core::sdk_name;
7use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
8use starweaver_runtime::AgentStreamRecord;
9use starweaver_session::{ApprovalStatus, RunStatus};
10use starweaver_stream::DisplayMessage;
11
12use crate::{
13 args::{
14 ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
15 DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
16 OutputMode, ResumeCommand, RunCommand, SessionCommand, TuiCommand,
17 },
18 config::{read_current_session, write_current_session, CliConfig},
19 environment::{resolve_environment_for_session, validate_environment_config},
20 local_store::LocalStore,
21 profiles::{list_profiles, resolve_profile},
22 prompt_input::PromptInput,
23 runner::{
24 execute_agent_session, execute_agent_session_with_channels, failed_display_message,
25 CliRunPolicy, CliSteeringMessage,
26 },
27 slash_commands::expand_slash_command,
28 CliError, CliResult,
29};
30
31mod auth;
32mod catalog;
33mod rendering;
34mod setup;
35mod tui;
36mod worktree;
37
38use auth::oauth_cli_error;
39use rendering::{
40 approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
41 render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
42 render_session_delete, render_session_show, render_sessions, render_trim_report, session_value,
43};
44use setup::remove_file_if_exists;
45#[cfg(test)]
46use tui::model_choices;
47use worktree::apply_starweaver_run_metadata;
48
49struct PromptRunExecution {
50 session_id: String,
51 run_id: String,
52 status: String,
53 output_mode: OutputMode,
54 messages: Vec<DisplayMessage>,
55}
56
57const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
58const USER_RULES_TAG: &str = "user-rules";
59
60fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
61 if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
62 input.push_guidance_text_part(project_guidance);
63 }
64 if let Some(user_rules) = load_user_rules(&config.global_dir) {
65 input.push_guidance_text_part(user_rules);
66 }
67}
68
69fn load_project_guidance(workspace_root: &Path) -> Option<String> {
70 let path = workspace_root.join("AGENTS.md");
71 let content = read_non_empty_utf8_file(&path)?;
72 Some(format!(
73 "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
74 ))
75}
76
77fn load_user_rules(global_dir: &Path) -> Option<String> {
78 let path = global_dir.join("RULES.md");
79 let content = read_non_empty_utf8_file(&path)?;
80 Some(format!(
81 "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
82 path_absolute_posix(&path)
83 ))
84}
85
86fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
87 let content = fs::read_to_string(path).ok()?;
88 (!content.trim().is_empty()).then_some(content)
89}
90
91fn path_absolute_posix(path: &Path) -> String {
92 path.canonicalize()
93 .unwrap_or_else(|_| path.to_path_buf())
94 .display()
95 .to_string()
96 .replace('\\', "/")
97}
98
99#[allow(dead_code)]
100struct OAuthRefreshGuard {
101 stop_sender: mpsc::Sender<()>,
102 handle: Option<thread::JoinHandle<()>>,
103}
104
105impl Drop for OAuthRefreshGuard {
106 fn drop(&mut self) {
107 let _ = self.stop_sender.send(());
108 if let Some(handle) = self.handle.take() {
109 let _ = handle.join();
110 }
111 }
112}
113
114pub struct CliService {
116 config: CliConfig,
117 store: Option<LocalStore>,
118}
119
120impl CliService {
121 pub const fn open(config: CliConfig) -> CliResult<Self> {
123 Ok(Self {
124 config,
125 store: None,
126 })
127 }
128
129 fn store(&mut self) -> CliResult<&mut LocalStore> {
130 if self.store.is_none() {
131 self.store = Some(LocalStore::open(&self.config)?);
132 }
133 self.store
134 .as_mut()
135 .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
136 }
137
138 pub fn execute(mut self, cli: Cli) -> CliResult<String> {
140 if let Some(prompt) = cli.prompt.clone() {
141 let command = RunCommand {
142 prompt: Some(prompt),
143 prompt_parts: Vec::new(),
144 session: cli.session.clone(),
145 continue_session: cli.continue_session,
146 new_session: cli.new_session,
147 run: cli.run.clone(),
148 branch_from: cli.branch_from.clone(),
149 profile: cli.profile.clone(),
150 output: cli.output,
151 hitl: cli.hitl,
152 worker: cli.worker.clone(),
153 worker_label: cli.worker_label.clone(),
154 worktree: cli.worktree.clone(),
155 worktree_name: cli.worktree_name.clone(),
156 branch: cli.branch,
157 session_affinity_id: None,
158 };
159 return self.run_prompt(&command);
160 }
161 let default_command = CliCommand::Tui(TuiCommand {
162 session: cli.session.clone(),
163 run: cli.run.clone(),
164 after: None,
165 interactive: false,
166 snapshot: false,
167 output: OutputMode::Text,
168 });
169 match cli.command.unwrap_or(default_command) {
170 CliCommand::Version => Ok(format!("{}\n", sdk_name())),
171 CliCommand::Diagnostics => Ok(self.diagnostics()?),
172 CliCommand::ReplayCheck => {
173 Ok("run `make replay-check` from the repository root\n".to_string())
174 }
175 CliCommand::Update(command) => Self::update(&command),
176 CliCommand::Run(command) => self.run_prompt(&command),
177 CliCommand::Rpc(_) => Err(CliError::Usage(
178 "rpc owns stdin/stdout and must be run through run_from_env".to_string(),
179 )),
180 CliCommand::Session { command } => self.session(command),
181 CliCommand::Profile { command } => self.profile(command),
182 CliCommand::Setup(command) => self.setup(&command),
183 CliCommand::Auth { command } => Self::auth(command),
184 CliCommand::Skill { command } => self.skills(command),
185 CliCommand::Subagent { command } => self.subagents(command),
186 CliCommand::Mcp { command } => self.mcp(command),
187 CliCommand::Tools { command } => self.tools(&command),
188 CliCommand::Tui(command) => self.tui(&command),
189 CliCommand::Approval { command } => self.approval(command),
190 CliCommand::Deferred { command } => self.deferred(command),
191 CliCommand::Resume(command) => self.resume(&command),
192 CliCommand::Reset(command) => self.reset(&command),
193 CliCommand::Config { command } => self.config(command),
194 CliCommand::Completion { shell } => render_completion(shell),
195 }
196 }
197
198 pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
199 let execution = self.execute_prompt_run(command, None)?;
200 match execution.output_mode {
201 OutputMode::Text => Ok(render_display_text(&execution.messages)),
202 OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
203 OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
204 OutputMode::Json => render_prompt_run_json(&execution),
205 OutputMode::Silent => Ok(format!(
206 "session_id={}\nrun_id={}\nstatus={}\n",
207 execution.session_id, execution.run_id, execution.status
208 )),
209 }
210 }
211
212 fn execute_prompt_run(
213 &mut self,
214 command: &RunCommand,
215 stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
216 ) -> CliResult<PromptRunExecution> {
217 self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
218 }
219
220 fn execute_prompt_run_with_steering(
221 &mut self,
222 command: &RunCommand,
223 prompt_input: Option<PromptInput>,
224 stream_sender: mpsc::Sender<AgentStreamRecord>,
225 steering_receiver: mpsc::Receiver<CliSteeringMessage>,
226 cancel_receiver: mpsc::Receiver<()>,
227 ) -> CliResult<PromptRunExecution> {
228 self.execute_prompt_run_with_channels(
229 command,
230 prompt_input,
231 Some(stream_sender),
232 Some(steering_receiver),
233 Some(cancel_receiver),
234 )
235 }
236
237 #[allow(clippy::too_many_lines)]
238 fn execute_prompt_run_with_channels(
239 &mut self,
240 command: &RunCommand,
241 prompt_input: Option<PromptInput>,
242 stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
243 steering_receiver: Option<mpsc::Receiver<CliSteeringMessage>>,
244 cancel_receiver: Option<mpsc::Receiver<()>>,
245 ) -> CliResult<PromptRunExecution> {
246 let input =
247 prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
248 let raw_prompt = input.text.clone();
249 let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
250 let prompt = slash_expansion
251 .as_ref()
252 .map_or(raw_prompt, |expanded| expanded.prompt.clone());
253 let mut run_input = PromptInput {
254 text: prompt.clone(),
255 attachments: input.attachments,
256 extra_text_parts: input.extra_text_parts,
257 guidance_text_parts: input.guidance_text_parts,
258 };
259 let worktree = self.resolve_worktree(command)?;
260 let selected_profile = command
261 .profile
262 .as_deref()
263 .unwrap_or(&self.config.default_profile);
264 let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
265 let mut run_config = self.config.clone();
266 if let Some(worktree) = worktree.as_ref() {
267 run_config.workspace_root.clone_from(&worktree.path);
268 }
269 validate_environment_config(&run_config)?;
270 append_guidance_files(&mut run_input, &run_config);
271 let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
272 let environment = resolve_environment_for_session(&run_config, &session_id)?;
273 let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
274 if restore_from.is_none() && !created {
275 restore_from = self
276 .store()?
277 .load_session(&session_id)
278 .ok()
279 .and_then(|session| {
280 session
281 .active_run_id
282 .or(session.head_run_id)
283 .or(session.head_success_run_id)
284 .map(|run| run.as_str().to_string())
285 });
286 }
287 let restore_state = self
288 .store()?
289 .load_restore_state(&session_id, restore_from.as_deref())?;
290 let mut run =
291 self.store()?
292 .append_run(&session_id, prompt, restore_from, &resolved_profile.name)?;
293 apply_starweaver_run_metadata(
294 &mut run,
295 command,
296 worktree.as_ref(),
297 slash_expansion.as_ref(),
298 );
299 if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
300 run.metadata.insert(
301 "starweaver.session_affinity_id".to_string(),
302 json!(session_affinity_id),
303 );
304 }
305 write_current_session(&self.config, &session_id)?;
306 let hitl = command.hitl.unwrap_or(self.config.default_hitl);
307 let output_mode = command.output.unwrap_or(self.config.default_output);
308 let result = if stream_sender.is_some()
309 || steering_receiver.is_some()
310 || cancel_receiver.is_some()
311 {
312 execute_agent_session_with_channels(
313 run_input,
314 &run,
315 &resolved_profile,
316 &environment.provider,
317 environment.process_provider.as_ref(),
318 restore_state,
319 CliRunPolicy { hitl },
320 stream_sender,
321 steering_receiver,
322 cancel_receiver,
323 )
324 } else {
325 execute_agent_session(
326 run_input,
327 &run,
328 &resolved_profile,
329 &environment.provider,
330 environment.process_provider.as_ref(),
331 restore_state,
332 CliRunPolicy { hitl },
333 )
334 };
335 let execution = match result {
336 Ok(execution) => execution,
337 Err(error) => {
338 let messages = failed_display_message(&run, &error.to_string());
339 self.store()?
340 .fail_run_with_messages(&mut run, error.to_string(), &messages)?;
341 return Err(error);
342 }
343 };
344 let execution_failed = execution.artifacts.status == RunStatus::Failed;
345 let output = execution.output;
346 let messages = self
347 .store()?
348 .complete_run(&mut run, output.clone(), execution.artifacts)?;
349 if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
350 return Err(CliError::Run(output));
351 }
352 if self.config.auto_trim {
353 let keep_runs = self.config.current_session_keep_recent_runs;
354 let _report = self
355 .store()?
356 .trim(vec![session_id.clone()], keep_runs, false)?;
357 }
358 Ok(PromptRunExecution {
359 session_id,
360 run_id: run.run_id.as_str().to_string(),
361 status: run_status_name(run.status).to_string(),
362 output_mode,
363 messages,
364 })
365 }
366
367 fn resolve_session(
368 &mut self,
369 command: &RunCommand,
370 profile: &str,
371 ) -> CliResult<(String, bool)> {
372 if command.new_session {
373 let session = self
374 .store()?
375 .create_session(profile, Some("CLI session".to_string()))?;
376 return Ok((session.session_id.as_str().to_string(), true));
377 }
378 if let Some(session_id) = command.session.as_ref() {
379 self.store()?.load_session(session_id)?;
380 return Ok((session_id.clone(), false));
381 }
382 if command.continue_session {
383 if let Some(session_id) = read_current_session(&self.config)? {
384 if self.store()?.load_session(&session_id).is_ok() {
385 return Ok((session_id, false));
386 }
387 }
388 if let Some(session) = self.store()?.latest_session()? {
389 return Ok((session.session_id.as_str().to_string(), false));
390 }
391 }
392 let session = self
393 .store()?
394 .create_session(profile, Some("CLI session".to_string()))?;
395 Ok((session.session_id.as_str().to_string(), true))
396 }
397
398 fn session(&mut self, command: SessionCommand) -> CliResult<String> {
399 match command {
400 SessionCommand::List(command) => {
401 let sessions = self.store()?.list_sessions(command.limit)?;
402 render_sessions(&sessions, command.output)
403 }
404 SessionCommand::Show(command) => {
405 let session = self.store()?.load_session(&command.session_id)?;
406 let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
407 let value = session_value(&session);
408 render_session_show(&value, &runs, command.output)
409 }
410 SessionCommand::Replay(command) => {
411 let messages = self.store()?.replay_display(
412 &command.session_id,
413 command.run.as_deref(),
414 command.after,
415 )?;
416 match command.output {
417 OutputMode::Text => Ok(render_display_text(&messages)),
418 OutputMode::DisplayJsonl => render_display_jsonl(&messages),
419 OutputMode::AguiJsonl => render_agui_jsonl(&messages),
420 OutputMode::Json => Ok(format!(
421 "{}\n",
422 serde_json::to_string(&json!({
423 "sessionId": command.session_id,
424 "runId": command.run,
425 "messages": messages,
426 "status": "replayed"
427 }))?
428 )),
429 OutputMode::Silent => Ok(format!(
430 "session_id={}\nmessages={}\nstatus=replayed\n",
431 command.session_id,
432 messages.len()
433 )),
434 }
435 }
436 SessionCommand::Delete(command) => {
437 if !command.yes {
438 return Err(CliError::Usage(
439 "pass --yes to delete a local session".to_string(),
440 ));
441 }
442 let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
443 let deleted = self.store()?.delete_session(&session_id)?;
444 if read_current_session(&self.config)?.as_deref() == Some(session_id.as_str()) {
445 let _removed =
446 remove_file_if_exists(&self.config.project_dir.join("state.json"))?;
447 }
448 render_session_delete(&session_id, deleted, command.output)
449 }
450 SessionCommand::Trim(command) => {
451 let sessions = if command.all {
452 self.store()?.all_session_ids()?
453 } else if let Some(session_id) = command.session {
454 vec![session_id]
455 } else {
456 read_current_session(&self.config)?.into_iter().collect()
457 };
458 let older_than = command
459 .older_than
460 .as_deref()
461 .map(parse_duration)
462 .transpose()?;
463 let report = self.store()?.trim_with_age(
464 sessions,
465 command.keep_runs,
466 older_than,
467 command.dry_run,
468 )?;
469 render_trim_report(&report, command.output)
470 }
471 }
472 }
473
474 fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
475 match command {
476 ApprovalCommand::List(command) => self.approval_list(&command),
477 ApprovalCommand::Show { approval_id } => {
478 let approval = self.store()?.load_approval(&approval_id)?;
479 Ok(format!("{}\n", serde_json::to_string(&approval)?))
480 }
481 ApprovalCommand::Approve(command) => {
482 self.approval_decision(&command, ApprovalStatus::Approved)
483 }
484 ApprovalCommand::Reject(command) => {
485 self.approval_decision(&command, ApprovalStatus::Denied)
486 }
487 }
488 }
489
490 fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
491 let approvals = self
492 .store()?
493 .list_approvals(command.session.as_deref(), command.run.as_deref())?;
494 render_approvals(&approvals, command.output)
495 }
496
497 fn approval_decision(
498 &mut self,
499 command: &ApprovalDecisionCommand,
500 status: ApprovalStatus,
501 ) -> CliResult<String> {
502 let approval =
503 self.store()?
504 .decide_approval(&command.approval_id, status, command.reason.clone())?;
505 match command.output {
506 OutputMode::Text => Ok(format!(
507 "approval_id={}\nstatus={}\nrun_id={}\n",
508 approval.approval_id,
509 approval_status_name(approval.status),
510 approval.run_id.as_str()
511 )),
512 OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
513 Ok(format!("{}\n", serde_json::to_string(&approval)?))
514 }
515 OutputMode::Silent => Ok(format!(
516 "approval_id={}\nstatus={}\n",
517 approval.approval_id,
518 approval_status_name(approval.status)
519 )),
520 }
521 }
522
523 fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
524 match command {
525 DeferredCommand::List(command) => self.deferred_list(&command),
526 DeferredCommand::Show { deferred_id } => {
527 let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
528 Ok(format!("{}\n", serde_json::to_string(&deferred)?))
529 }
530 DeferredCommand::Complete(command) => self.deferred_complete(&command),
531 DeferredCommand::Fail(command) => self.deferred_fail(&command),
532 }
533 }
534
535 fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
536 let records = self
537 .store()?
538 .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
539 render_deferred(&records, command.output)
540 }
541
542 fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
543 let value = serde_json::from_str::<Value>(&command.result)
544 .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
545 let record = self
546 .store()?
547 .complete_deferred_tool(&command.deferred_id, value)?;
548 render_deferred_decision(&record, command.output)
549 }
550
551 fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
552 let record = self
553 .store()?
554 .fail_deferred_tool(&command.deferred_id, &command.error)?;
555 render_deferred_decision(&record, command.output)
556 }
557
558 fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
559 let session_id = self.resolve_session_id(command.session.as_deref())?;
560 let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
561 let run_command = RunCommand {
562 prompt: Some(format!(
563 "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
564 command.prompt,
565 source_run.run_id.as_str()
566 )),
567 prompt_parts: Vec::new(),
568 session: Some(session_id),
569 continue_session: false,
570 new_session: false,
571 run: Some(source_run.run_id.as_str().to_string()),
572 branch_from: None,
573 profile: source_run.profile.clone(),
574 output: command.output,
575 hitl: command.hitl,
576 worker: None,
577 worker_label: None,
578 worktree: None,
579 worktree_name: None,
580 branch: None,
581 session_affinity_id: None,
582 };
583 self.run_prompt(&run_command)
584 }
585
586 fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
587 if let Some(session_id) = requested {
588 self.store()?.load_session(session_id)?;
589 return Ok(session_id.to_string());
590 }
591 if let Some(session_id) = read_current_session(&self.config)? {
592 if self.store()?.load_session(&session_id).is_ok() {
593 return Ok(session_id);
594 }
595 }
596 self.store()?
597 .latest_session()?
598 .map(|session| session.session_id.as_str().to_string())
599 .ok_or_else(|| CliError::NotFound("session".to_string()))
600 }
601
602 fn resolve_resume_run(
603 &mut self,
604 session_id: &str,
605 requested: Option<&str>,
606 ) -> CliResult<starweaver_session::RunRecord> {
607 if let Some(run_id) = requested {
608 return self.store()?.load_run(session_id, run_id);
609 }
610 let session = self.store()?.load_session(session_id)?;
611 let run_id = session
612 .active_run_id
613 .as_ref()
614 .or(session.head_run_id.as_ref())
615 .ok_or_else(|| CliError::NotFound("run".to_string()))?;
616 self.store()?.load_run(session_id, run_id.as_str())
617 }
618}
619
620#[allow(dead_code)]
621fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
622 if !config.oauth_refresh.enabled {
623 return Ok(None);
624 }
625 let models = list_profiles(config)
626 .into_iter()
627 .map(|profile| profile.model_id)
628 .collect::<Vec<_>>();
629 if config.oauth_refresh.interval_seconds == 0 {
630 return Err(CliError::Usage(
631 "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
632 ));
633 }
634 if config.oauth_refresh.failure_retry_seconds == 0 {
635 return Err(CliError::Usage(
636 "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
637 ));
638 }
639 let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
640 models.iter().map(String::as_str),
641 Duration::from_secs(config.oauth_refresh.interval_seconds),
642 Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
643 config.oauth_refresh.refresh_on_startup,
644 )
645 .map_err(oauth_cli_error)?;
646 let Some(mut supervisor) = supervisor.take() else {
647 return Ok(None);
648 };
649 let (stop_sender, stop_receiver) = mpsc::channel::<()>();
650 let handle = thread::Builder::new()
651 .name("starweaver-oauth-refresh".to_string())
652 .spawn(move || {
653 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
654 .enable_all()
655 .build()
656 else {
657 return;
658 };
659 runtime.block_on(async move {
660 supervisor.start().await;
661 let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
662 supervisor.shutdown().await;
663 });
664 })
665 .map_err(|error| CliError::Run(error.to_string()))?;
666 Ok(Some(OAuthRefreshGuard {
667 stop_sender,
668 handle: Some(handle),
669 }))
670}
671
672fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
673 items
674 .iter()
675 .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
676 .collect::<Result<String, _>>()
677 .map_err(CliError::from)
678}
679
680const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
681 match status {
682 starweaver_session::RunStatus::Queued => "queued",
683 starweaver_session::RunStatus::Running => "running",
684 starweaver_session::RunStatus::Waiting => "waiting",
685 starweaver_session::RunStatus::Completed => "completed",
686 starweaver_session::RunStatus::Failed => "failed",
687 starweaver_session::RunStatus::Cancelled => "cancelled",
688 }
689}
690
691fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
692 let trimmed = value.trim();
693 if trimmed.is_empty() {
694 return Err(CliError::Usage("duration cannot be empty".to_string()));
695 }
696 let (number, unit) = trimmed.split_at(
697 trimmed
698 .find(|ch: char| !ch.is_ascii_digit())
699 .unwrap_or(trimmed.len()),
700 );
701 let amount = number
702 .parse::<i64>()
703 .map_err(|error| CliError::Usage(error.to_string()))?;
704 let duration = match unit {
705 "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
706 "m" | "min" | "mins" => chrono::Duration::minutes(amount),
707 "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
708 "d" | "day" | "days" => chrono::Duration::days(amount),
709 other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
710 };
711 Ok(duration)
712}
713
714#[cfg(test)]
715mod tests;