Skip to main content

starweaver_cli/
service.rs

1//! CLI service layer over local storage and SDK execution.
2
3use 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
114/// CLI service.
115pub struct CliService {
116    config: CliConfig,
117    store: Option<LocalStore>,
118}
119
120impl CliService {
121    /// Open service from resolved config.
122    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    /// Execute a parsed CLI command.
139    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;