1use std::{collections::BTreeSet, fs, path::Path, path::PathBuf, sync::Arc};
4
5use chrono::Utc;
6use serde::Serialize;
7use serde_json::Value;
8use starweaver_agent::ResumableState;
9use starweaver_core::{ConversationId, RunId, SessionId};
10use starweaver_environment::EnvironmentState;
11use starweaver_model::{ModelMessage, ModelRequest, ModelRequestPart, ToolReturnPart};
12use starweaver_runtime::{AgentStreamEvent, AgentStreamRecord};
13use starweaver_session::{
14 ApprovalRecord, ApprovalStatus, DeferredToolRecord, EnvironmentStateRef, ExecutionStatus,
15 InputPart, RunRecord, RunStatus, SessionRecord, SessionSearchError, SessionSearchPage,
16 SessionSearchProvider, SessionSearchQuery, SessionSearchScope, SessionStatus,
17 SessionStoreError, StreamCursorRef,
18};
19use starweaver_storage::{LocalSessionSearchProvider, RunEvidenceCommit, SqliteStorage};
20use starweaver_stream::{DisplayMessage, ReplayCursor, ReplayScope, ReplaySnapshot};
21use uuid::Uuid;
22
23use crate::{CliError, CliResult, config::CliConfig, error::io_error};
24
25mod archive;
26mod hitl;
27mod replay;
28mod session_store;
29
30pub use archive::LocalStreamArchive;
31use hitl::{
32 approval_tool_return, deferred_status_is_unresolved, deferred_tool_return,
33 existing_resume_tool_return_ids, latest_tool_call_order, pending_hitl_resume_error,
34 tool_return_control_flow,
35};
36pub use replay::DisplayReplayWindow;
37pub use session_store::LocalSessionStore;
38
39pub struct LocalStore {
41 storage: SqliteStorage,
42 file_store_path: PathBuf,
43 search_scope: SessionSearchScope,
44}
45
46pub struct RunArtifacts {
48 pub state: ResumableState,
50 pub environment_state: Option<EnvironmentState>,
52 pub raw_records: Vec<AgentStreamRecord>,
54 pub display_messages: Vec<DisplayMessage>,
56 pub display_snapshot: ReplaySnapshot,
58 pub approvals: Vec<ApprovalRecord>,
60 pub deferred_tools: Vec<DeferredToolRecord>,
62 pub status: RunStatus,
64}
65
66#[derive(Clone, Debug, Serialize)]
68pub struct SessionSummary {
69 pub session_id: String,
71 pub title: Option<String>,
73 pub profile: Option<String>,
75 pub status: String,
77 pub head_run_id: Option<String>,
79 pub head_success_run_id: Option<String>,
81 pub active_run_id: Option<String>,
83 pub run_count: usize,
85 pub last_output_preview: Option<String>,
87 pub created_at: String,
89 pub updated_at: String,
91}
92
93#[derive(Clone, Debug, Serialize)]
95pub struct RunSummary {
96 pub run_id: String,
98 pub sequence_no: usize,
100 pub status: String,
102 pub restore_from_run_id: Option<String>,
104 pub output_preview: Option<String>,
106 pub created_at: String,
108 pub updated_at: String,
110}
111
112#[derive(Clone, Debug, Default, Serialize)]
114pub struct TrimReport {
115 pub sessions_scanned: usize,
117 pub runs_to_trim: usize,
119 pub runs_trimmed: usize,
121 pub bytes_reclaimed: u64,
123 pub dry_run: bool,
125}
126
127impl LocalStore {
128 pub fn open(config: &CliConfig) -> CliResult<Self> {
130 crate::config::ensure_config_dirs(config)?;
131 Ok(Self {
132 storage: SqliteStorage::open(&config.database_path).map_err(storage_error)?,
133 file_store_path: config.file_store_path.clone(),
134 search_scope: SessionSearchScope::local(
135 config.database_path.to_string_lossy().into_owned(),
136 ),
137 })
138 }
139
140 pub fn create_session(
142 &mut self,
143 profile: &str,
144 title: Option<String>,
145 ) -> CliResult<SessionRecord> {
146 self.storage
147 .create_session(Some(profile.to_string()), title)
148 .map_err(storage_error)
149 }
150
151 pub fn load_session(&self, session_id: &str) -> CliResult<SessionRecord> {
153 self.storage
154 .load_session(&SessionId::from_string(session_id))
155 .map_err(storage_error)
156 }
157
158 pub fn resolve_session_prefix(&self, value: &str) -> CliResult<String> {
160 self.storage
161 .resolve_session_prefix(value)
162 .map(|session_id| session_id.as_str().to_string())
163 .map_err(|error| match error {
164 SessionStoreError::NotFound(_) => CliError::NotFound(value.to_string()),
165 SessionStoreError::Failed(message) if message.contains("ambiguous") => {
166 CliError::Usage(message)
167 }
168 other => storage_error(other),
169 })
170 }
171
172 pub fn delete_session(&mut self, session_id: &str) -> CliResult<bool> {
174 let session_id = SessionId::from_string(session_id);
175 let deleted = self
176 .storage
177 .delete_session(&session_id)
178 .map_err(storage_error)?;
179 if deleted {
180 let path = self
181 .file_store_path
182 .join("sessions")
183 .join(session_id.as_str());
184 if path.exists() {
185 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
186 }
187 }
188 Ok(deleted)
189 }
190
191 pub fn load_run(&self, session_id: &str, run_id: &str) -> CliResult<RunRecord> {
193 self.storage
194 .load_run(
195 &SessionId::from_string(session_id),
196 &RunId::from_string(run_id),
197 )
198 .map_err(storage_error)
199 }
200
201 pub fn latest_session(&self) -> CliResult<Option<SessionRecord>> {
203 Ok(self
204 .storage
205 .list_sessions()
206 .map_err(storage_error)?
207 .into_iter()
208 .find(|session| session.status == SessionStatus::Active))
209 }
210
211 pub fn append_run(
213 &mut self,
214 session_id: &str,
215 prompt: String,
216 restore_from_run_id: Option<String>,
217 profile: &str,
218 ) -> CliResult<RunRecord> {
219 let session = self.load_session(session_id)?;
220 let mut run = RunRecord::new(session.session_id, RunId::new(), ConversationId::new());
221 run.restore_from_run_id = restore_from_run_id.map(RunId::from_string);
222 run.trigger_type = Some("cli".to_string());
223 run.profile = Some(profile.to_string());
224 run.input = vec![InputPart::text(prompt)];
225 self.storage.begin_run(run).map_err(storage_error)
226 }
227
228 pub fn complete_run(
230 &mut self,
231 run: &mut RunRecord,
232 output: String,
233 artifacts: RunArtifacts,
234 ) -> CliResult<Vec<DisplayMessage>> {
235 let scope = ReplayScope::run(run.run_id.as_str());
236 let mut display_snapshot = artifacts.display_snapshot.clone();
237 if display_snapshot.scope.is_none() {
238 display_snapshot.scope = Some(scope.clone());
239 }
240 let raw_cursor = artifacts.raw_records.last().map(|record| {
241 StreamCursorRef::new(ReplayCursor::raw_runtime(scope.clone(), record.sequence))
242 });
243 let display_cursor = artifacts.display_messages.last().map(|message| {
244 StreamCursorRef::new(ReplayCursor::display(scope.clone(), message.sequence))
245 });
246 let environment_ref =
247 artifacts
248 .environment_state
249 .as_ref()
250 .map(|state| EnvironmentStateRef {
251 provider: state.provider_id.clone(),
252 reference: format!(
253 "sqlite:run_environment_records/{}/{}",
254 run.session_id.as_str(),
255 run.run_id.as_str()
256 ),
257 revision: Some(format!("{}", state.files.len() + state.resources.len())),
258 metadata: state.metadata.clone(),
259 });
260 run.status = artifacts.status;
261 run.output_preview = Some(output);
262 run.updated_at = Utc::now();
263 run.environment_state.clone_from(&environment_ref);
264 run.stream_cursors = raw_cursor.into_iter().chain(display_cursor).collect();
265
266 let mut commit = RunEvidenceCommit::new(run.clone(), artifacts.state.clone());
267 commit.environment_state = artifacts
268 .environment_state
269 .as_ref()
270 .map(EnvironmentState::to_json);
271 commit.stream_records.clone_from(&artifacts.raw_records);
272 commit.approvals.clone_from(&artifacts.approvals);
273 commit.deferred_tools.clone_from(&artifacts.deferred_tools);
274 commit.stream_cursors.clone_from(&run.stream_cursors);
275 commit
276 .display_messages
277 .clone_from(&artifacts.display_messages);
278 commit.display_snapshot = Some(display_snapshot.clone());
279 *run = self
280 .storage
281 .commit_run_evidence(commit)
282 .map_err(storage_error)?;
283
284 self.write_run_blob(run, "raw.stream.json", &artifacts.raw_records)?;
288 self.write_run_blob(run, "display.compact.json", &display_snapshot)?;
289 self.write_run_blob(run, "context.state.json", &artifacts.state)?;
290 if let Some(environment_state) = artifacts.environment_state.as_ref() {
291 self.write_run_blob(run, "environment.state.json", &environment_state.to_json())?;
292 }
293 Ok(artifacts.display_messages)
294 }
295
296 pub fn fail_run(&mut self, run: &mut RunRecord, message: String) -> CliResult<()> {
298 self.fail_run_with_messages(run, message, &[])
299 }
300
301 pub fn fail_run_with_messages(
303 &mut self,
304 run: &mut RunRecord,
305 message: String,
306 messages: &[DisplayMessage],
307 ) -> CliResult<()> {
308 let session = self.load_session(run.session_id.as_str())?;
309 run.status = RunStatus::Failed;
310 run.output_preview = Some(message);
311 run.updated_at = Utc::now();
312 let mut commit = RunEvidenceCommit::new(run.clone(), session.state);
313 commit.display_messages = messages.to_vec();
314 *run = self
315 .storage
316 .commit_run_evidence(commit)
317 .map_err(storage_error)?;
318 self.write_run_blob(run, "display.compact.json", &messages)?;
319 Ok(())
320 }
321
322 pub fn load_restore_state(
324 &self,
325 session_id: &str,
326 run_id: Option<&str>,
327 ) -> CliResult<Option<ResumableState>> {
328 let Some(run_id) = run_id else {
329 return Ok(Some(self.load_session(session_id)?.state));
330 };
331 let mut state = self
332 .storage
333 .load_run_context(
334 &SessionId::from_string(session_id),
335 &RunId::from_string(run_id),
336 )
337 .map_err(storage_error)?;
338 if let Some(state) = state.as_mut() {
339 self.inject_resolved_hitl_tool_returns(session_id, run_id, state)?;
340 }
341 Ok(state)
342 }
343
344 fn inject_resolved_hitl_tool_returns(
345 &self,
346 session_id: &str,
347 run_id: &str,
348 state: &mut ResumableState,
349 ) -> CliResult<()> {
350 let mut existing_returns = existing_resume_tool_return_ids(&state.message_history);
351 let tool_call_order = latest_tool_call_order(&state.message_history);
352 let latest_tool_call_ids = tool_call_order.iter().cloned().collect::<BTreeSet<_>>();
353 let approvals = self.list_approvals(Some(session_id), Some(run_id))?;
354 let deferred_tools = self.list_deferred_tools(Some(session_id), Some(run_id))?;
355 let pending_approvals = approvals
356 .iter()
357 .filter(|approval| {
358 approval.status == ApprovalStatus::Pending
359 && !existing_returns.contains(&approval.action_id)
360 })
361 .map(|approval| approval.approval_id.clone())
362 .collect::<Vec<_>>();
363 let pending_deferred = deferred_tools
364 .iter()
365 .filter(|deferred| {
366 deferred_status_is_unresolved(deferred.status)
367 && !existing_returns.contains(&deferred.tool_call_id)
368 })
369 .map(|deferred| deferred.deferred_id.clone())
370 .collect::<Vec<_>>();
371 if !pending_approvals.is_empty() || !pending_deferred.is_empty() {
372 return Err(pending_hitl_resume_error(
373 run_id,
374 &pending_approvals,
375 &pending_deferred,
376 ));
377 }
378
379 let mut resolved = Vec::<(String, ModelRequestPart)>::new();
380 for tool_return in self.list_run_tool_returns(session_id, run_id)? {
381 if !latest_tool_call_ids.contains(&tool_return.tool_call_id)
382 || tool_return_control_flow(&tool_return).is_some()
383 || existing_returns.contains(&tool_return.tool_call_id)
384 {
385 continue;
386 }
387 existing_returns.insert(tool_return.tool_call_id.clone());
388 resolved.push((
389 tool_return.tool_call_id.clone(),
390 ModelRequestPart::ToolReturn(tool_return),
391 ));
392 }
393 for approval in approvals {
394 if existing_returns.contains(&approval.action_id) {
395 continue;
396 }
397 if let Some(tool_return) = approval_tool_return(&approval) {
398 existing_returns.insert(approval.action_id.clone());
399 resolved.push((
400 approval.action_id.clone(),
401 ModelRequestPart::ToolReturn(tool_return),
402 ));
403 }
404 }
405 for deferred in deferred_tools {
406 if existing_returns.contains(&deferred.tool_call_id) {
407 continue;
408 }
409 if let Some(tool_return) = deferred_tool_return(&deferred) {
410 existing_returns.insert(deferred.tool_call_id.clone());
411 resolved.push((
412 deferred.tool_call_id.clone(),
413 ModelRequestPart::ToolReturn(tool_return),
414 ));
415 }
416 }
417 if resolved.is_empty() {
418 return Ok(());
419 }
420 resolved.sort_by_key(|(tool_call_id, _)| {
421 tool_call_order
422 .iter()
423 .position(|known| known == tool_call_id)
424 .unwrap_or(usize::MAX)
425 });
426 let mut metadata = serde_json::Map::new();
427 metadata.insert(
428 "starweaver.resume.hitl_results".to_string(),
429 serde_json::json!(true),
430 );
431 metadata.insert(
432 "starweaver.resume.source_run_id".to_string(),
433 serde_json::json!(run_id),
434 );
435 state
436 .message_history
437 .push(ModelMessage::Request(ModelRequest {
438 parts: resolved.into_iter().map(|(_, part)| part).collect(),
439 timestamp: Some(Utc::now()),
440 instructions: None,
441 run_id: Some(RunId::from_string(run_id)),
442 conversation_id: state.conversation_id.clone(),
443 metadata,
444 }));
445 Ok(())
446 }
447
448 fn list_run_tool_returns(
449 &self,
450 session_id: &str,
451 run_id: &str,
452 ) -> CliResult<Vec<ToolReturnPart>> {
453 let records = self
454 .storage
455 .load_stream_records(
456 &SessionId::from_string(session_id),
457 &RunId::from_string(run_id),
458 )
459 .map_err(storage_error)?;
460 Ok(records
461 .into_iter()
462 .filter_map(|record| match record.event {
463 AgentStreamEvent::ToolReturn { tool_return, .. } => Some(tool_return),
464 _ => None,
465 })
466 .collect())
467 }
468
469 pub fn list_sessions(&self, limit: usize) -> CliResult<Vec<SessionSummary>> {
471 self.storage
472 .list_sessions()
473 .map_err(storage_error)?
474 .into_iter()
475 .take(limit)
476 .map(|session| {
477 let runs = self
478 .storage
479 .list_runs(&session.session_id)
480 .map_err(storage_error)?;
481 Ok(SessionSummary {
482 session_id: session.session_id.as_str().to_string(),
483 title: session.title,
484 profile: session.profile,
485 status: session_status_name(session.status).to_string(),
486 head_run_id: session.head_run_id.map(|id| id.as_str().to_string()),
487 head_success_run_id: session
488 .head_success_run_id
489 .map(|id| id.as_str().to_string()),
490 active_run_id: session.active_run_id.map(|id| id.as_str().to_string()),
491 run_count: runs.len(),
492 last_output_preview: runs.last().and_then(|run| run.output_preview.clone()),
493 created_at: session.created_at.to_rfc3339(),
494 updated_at: session.updated_at.to_rfc3339(),
495 })
496 })
497 .collect()
498 }
499
500 pub fn search_sessions(&self, query: SessionSearchQuery) -> CliResult<SessionSearchPage> {
502 let provider = LocalSessionSearchProvider::new(
503 Arc::new(self.storage.session_store()),
504 &self.search_scope,
505 )
506 .with_display_root(self.file_store_path.clone());
507 let runtime = tokio::runtime::Builder::new_current_thread()
508 .enable_all()
509 .build()
510 .map_err(|error| CliError::Run(error.to_string()))?;
511 runtime
512 .block_on(provider.search(&self.search_scope, query))
513 .map_err(search_error)
514 }
515
516 pub fn list_runs(&self, session_id: &str, limit: usize) -> CliResult<Vec<RunSummary>> {
518 let mut runs = self
519 .storage
520 .list_runs(&SessionId::from_string(session_id))
521 .map_err(storage_error)?;
522 if runs.len() > limit {
523 runs.drain(..runs.len() - limit);
524 }
525 Ok(runs.into_iter().map(run_summary).collect())
526 }
527
528 pub fn replay_display(
530 &self,
531 session_id: &str,
532 run_id: Option<&str>,
533 after: Option<usize>,
534 ) -> CliResult<Vec<DisplayMessage>> {
535 let session_id = SessionId::from_string(session_id);
536 let run_id = run_id.map(RunId::from_string);
537 self.storage
538 .load_display_messages(&session_id, run_id.as_ref(), after)
539 .map_err(storage_error)
540 }
541
542 pub fn trim(
544 &mut self,
545 sessions: Vec<String>,
546 keep_runs: usize,
547 dry_run: bool,
548 ) -> CliResult<TrimReport> {
549 self.trim_with_age(sessions, keep_runs, None, dry_run)
550 }
551
552 pub fn trim_with_age(
554 &mut self,
555 sessions: Vec<String>,
556 keep_runs: usize,
557 older_than: Option<chrono::Duration>,
558 dry_run: bool,
559 ) -> CliResult<TrimReport> {
560 let mut report = TrimReport {
561 sessions_scanned: sessions.len(),
562 dry_run,
563 ..TrimReport::default()
564 };
565 let cutoff = older_than.map(|duration| Utc::now() - duration);
566 for session_id in sessions {
567 let session_id = SessionId::from_string(session_id);
568 let session = self
569 .storage
570 .load_session(&session_id)
571 .map_err(storage_error)?;
572 let runs = self.storage.list_runs(&session_id).map_err(storage_error)?;
573 let keep_from = runs.len().saturating_sub(keep_runs);
574 let candidates = runs
575 .into_iter()
576 .take(keep_from)
577 .filter(|run| session.active_run_id.as_ref() != Some(&run.run_id))
578 .filter(|run| cutoff.is_none_or(|cutoff| run.updated_at < cutoff))
579 .collect::<Vec<_>>();
580 report.runs_to_trim += candidates.len();
581 for run in &candidates {
582 report.bytes_reclaimed = report
583 .bytes_reclaimed
584 .saturating_add(self.run_file_bytes(session_id.as_str(), run.run_id.as_str())?);
585 }
586 if !dry_run && !candidates.is_empty() {
587 let run_ids = candidates
588 .iter()
589 .map(|run| run.run_id.clone())
590 .collect::<Vec<_>>();
591 report.runs_trimmed += self
592 .storage
593 .prune_runs(&session_id, &run_ids)
594 .map_err(storage_error)?;
595 for run_id in run_ids {
596 self.remove_run_files(session_id.as_str(), run_id.as_str())?;
597 }
598 }
599 }
600 Ok(report)
601 }
602
603 pub fn all_session_ids(&self) -> CliResult<Vec<String>> {
605 Ok(self
606 .storage
607 .list_sessions()
608 .map_err(storage_error)?
609 .into_iter()
610 .map(|session| session.session_id.as_str().to_string())
611 .collect())
612 }
613
614 fn write_run_blob<T: Serialize>(
615 &self,
616 run: &RunRecord,
617 name: &str,
618 value: &T,
619 ) -> CliResult<()> {
620 let path = self
621 .file_store_path
622 .join("sessions")
623 .join(run.session_id.as_str())
624 .join("runs")
625 .join(run.run_id.as_str())
626 .join(name);
627 atomic_write_json(&path, value)
628 }
629
630 fn run_file_bytes(&self, session_id: &str, run_id: &str) -> CliResult<u64> {
631 directory_bytes(
632 &self
633 .file_store_path
634 .join("sessions")
635 .join(session_id)
636 .join("runs")
637 .join(run_id),
638 )
639 }
640
641 fn remove_run_files(&self, session_id: &str, run_id: &str) -> CliResult<()> {
642 let path = self
643 .file_store_path
644 .join("sessions")
645 .join(session_id)
646 .join("runs")
647 .join(run_id);
648 if path.exists() {
649 fs::remove_dir_all(&path).map_err(|error| io_error(&path, error))?;
650 }
651 Ok(())
652 }
653
654 pub fn list_approvals(
656 &self,
657 session_id: Option<&str>,
658 run_id: Option<&str>,
659 ) -> CliResult<Vec<ApprovalRecord>> {
660 let session_id = session_id.map(SessionId::from_string);
661 let run_id = run_id.map(RunId::from_string);
662 self.storage
663 .list_approvals(session_id.as_ref(), run_id.as_ref())
664 .map_err(storage_error)
665 }
666
667 pub fn load_approval(&self, approval_id: &str) -> CliResult<ApprovalRecord> {
669 self.storage
670 .load_approval(approval_id)
671 .map_err(storage_error)
672 }
673
674 pub fn decide_approval(
676 &mut self,
677 approval_id: &str,
678 status: ApprovalStatus,
679 reason: Option<String>,
680 ) -> CliResult<ApprovalRecord> {
681 self.storage
682 .decide_approval(
683 approval_id,
684 status,
685 Some("starweaver-cli".to_string()),
686 reason,
687 )
688 .map_err(storage_error)
689 }
690
691 pub fn list_deferred_tools(
693 &self,
694 session_id: Option<&str>,
695 run_id: Option<&str>,
696 ) -> CliResult<Vec<DeferredToolRecord>> {
697 let session_id = session_id.map(SessionId::from_string);
698 let run_id = run_id.map(RunId::from_string);
699 self.storage
700 .list_deferred_tools(session_id.as_ref(), run_id.as_ref())
701 .map_err(storage_error)
702 }
703
704 pub fn load_deferred_tool(&self, deferred_id: &str) -> CliResult<DeferredToolRecord> {
706 self.storage
707 .load_deferred_tool(deferred_id)
708 .map_err(storage_error)
709 }
710
711 pub fn complete_deferred_tool(
713 &mut self,
714 deferred_id: &str,
715 response: Value,
716 ) -> CliResult<DeferredToolRecord> {
717 self.storage
718 .resolve_deferred_tool(deferred_id, ExecutionStatus::Completed, response)
719 .map_err(storage_error)
720 }
721
722 pub fn fail_deferred_tool(
724 &mut self,
725 deferred_id: &str,
726 error: &str,
727 ) -> CliResult<DeferredToolRecord> {
728 self.storage
729 .resolve_deferred_tool(
730 deferred_id,
731 ExecutionStatus::Failed,
732 serde_json::json!({"error": error}),
733 )
734 .map_err(storage_error)
735 }
736}
737
738fn run_summary(run: RunRecord) -> RunSummary {
739 RunSummary {
740 run_id: run.run_id.as_str().to_string(),
741 sequence_no: run.sequence_no,
742 status: run_status_name(run.status).to_string(),
743 restore_from_run_id: run
744 .restore_from_run_id
745 .map(|run_id| run_id.as_str().to_string()),
746 output_preview: run.output_preview,
747 created_at: run.created_at.to_rfc3339(),
748 updated_at: run.updated_at.to_rfc3339(),
749 }
750}
751
752const fn run_status_name(status: RunStatus) -> &'static str {
753 status.as_str()
754}
755
756const fn session_status_name(status: SessionStatus) -> &'static str {
757 match status {
758 SessionStatus::Active => "active",
759 SessionStatus::Archived => "archived",
760 SessionStatus::Failed => "failed",
761 SessionStatus::Deleted => "deleted",
762 }
763}
764
765fn storage_error(error: SessionStoreError) -> CliError {
766 match error {
767 SessionStoreError::NotFound(value) => CliError::NotFound(value),
768 other => CliError::Storage(other.to_string()),
769 }
770}
771
772fn search_error(error: SessionSearchError) -> CliError {
773 match error {
774 SessionSearchError::InvalidQuery(message) | SessionSearchError::InvalidCursor(message) => {
775 CliError::Usage(message)
776 }
777 SessionSearchError::Unsupported(message) => CliError::Unsupported(message),
778 SessionSearchError::Unavailable(message) | SessionSearchError::Failed(message) => {
779 CliError::Storage(message)
780 }
781 SessionSearchError::PermissionDenied => {
782 CliError::Storage("session search permission denied".to_string())
783 }
784 }
785}
786
787fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> CliResult<()> {
788 let parent = path
789 .parent()
790 .ok_or_else(|| CliError::Storage("missing parent path".to_string()))?;
791 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
792 let temp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
793 fs::write(&temp, serde_json::to_vec_pretty(value)?).map_err(|error| io_error(&temp, error))?;
794 fs::rename(&temp, path).map_err(|error| io_error(path, error))
795}
796
797fn directory_bytes(path: &Path) -> CliResult<u64> {
798 if !path.exists() {
799 return Ok(0);
800 }
801 let mut total = 0_u64;
802 for entry in fs::read_dir(path).map_err(|error| io_error(path, error))? {
803 let entry = entry.map_err(|error| io_error(path, error))?;
804 let entry_path = entry.path();
805 let metadata = entry
806 .metadata()
807 .map_err(|error| io_error(&entry_path, error))?;
808 if metadata.is_dir() {
809 total = total.saturating_add(directory_bytes(&entry_path)?);
810 } else {
811 total = total.saturating_add(metadata.len());
812 }
813 }
814 Ok(total)
815}