systemprompt_loader/bundle/
cache.rs1use std::fs;
14use std::path::{Path, PathBuf};
15
16use systemprompt_models::services::bundle::{
17 BUNDLE_MANIFEST_FILE, ServicesBundleState, SignedBundleManifest,
18};
19
20use super::error::{BundleError, BundleResult};
21
22const STATE_FILE: &str = "state.json";
23const CURRENT_LINK: &str = "current";
24const KEEP_PER_BUNDLE: usize = 2;
25const KEEP_COMPOSED: usize = 2;
26
27#[derive(Debug, Clone)]
28pub struct BundleCache {
29 root: PathBuf,
30}
31
32pub fn discard_staging(path: &Path) {
33 if let Err(error) = fs::remove_dir_all(path) {
34 tracing::warn!(path = %path.display(), error = %error, "Failed to remove staging directory");
35 }
36}
37
38impl BundleCache {
39 #[must_use]
40 pub fn new(root: impl Into<PathBuf>) -> Self {
41 Self { root: root.into() }
42 }
43
44 #[must_use]
45 pub fn root(&self) -> &Path {
46 &self.root
47 }
48
49 pub fn prepare(&self) -> BundleResult<()> {
50 fs::create_dir_all(self.root.join("bundles"))?;
51 fs::create_dir_all(self.root.join("composed"))?;
52 Ok(())
53 }
54
55 #[must_use]
56 pub fn bundle_dir(&self, name: &str, content_hash: &str) -> PathBuf {
57 self.root.join("bundles").join(name).join(content_hash)
58 }
59
60 #[must_use]
61 pub fn composed_dir(&self, composed_hash: &str) -> PathBuf {
62 self.root.join("composed").join(composed_hash)
63 }
64
65 #[must_use]
66 pub fn current_link(&self) -> PathBuf {
67 self.root.join(CURRENT_LINK)
68 }
69
70 #[must_use]
71 pub fn current_root(&self) -> Option<PathBuf> {
72 let link = self.current_link();
73 link.exists().then_some(link)
74 }
75
76 #[must_use]
77 pub fn state_path(&self) -> PathBuf {
78 self.root.join(STATE_FILE)
79 }
80
81 #[must_use]
82 pub fn read_state(&self) -> ServicesBundleState {
83 fs::read_to_string(self.state_path())
84 .ok()
85 .and_then(|raw| serde_json::from_str(&raw).ok())
86 .unwrap_or_default()
87 }
88
89 pub fn read_manifest(
90 &self,
91 name: &str,
92 content_hash: &str,
93 ) -> BundleResult<SignedBundleManifest> {
94 let path = self
95 .bundle_dir(name, content_hash)
96 .join(BUNDLE_MANIFEST_FILE);
97 let raw = fs::read_to_string(&path)?;
98 serde_json::from_str(&raw).map_err(|e| {
99 BundleError::policy(format!("cached bundle.json for {name} does not parse: {e}"))
100 })
101 }
102
103 pub fn write_state(&self, state: &ServicesBundleState) -> BundleResult<()> {
104 self.prepare()?;
105 let body = serde_json::to_vec_pretty(state)
106 .map_err(|e| BundleError::policy(format!("state is not serialisable: {e}")))?;
107 let tmp = self
108 .root
109 .join(format!("{STATE_FILE}.tmp-{}", std::process::id()));
110 fs::write(&tmp, body)?;
111 fs::rename(&tmp, self.state_path())?;
112 Ok(())
113 }
114
115 pub fn swap_current(&self, target: &Path) -> BundleResult<()> {
116 self.prepare()?;
117 let staging = self
118 .root
119 .join(format!("{CURRENT_LINK}.tmp-{}", std::process::id()));
120 if staging.exists() || fs::symlink_metadata(&staging).is_ok() {
121 fs::remove_file(&staging)?;
122 }
123 symlink(target, &staging)?;
124 fs::rename(&staging, self.current_link())?;
125 Ok(())
126 }
127
128 pub fn gc(&self, keep_composed: &str) -> BundleResult<()> {
129 let bundles = self.root.join("bundles");
130 if bundles.is_dir() {
131 for entry in fs::read_dir(&bundles)? {
132 let dir = entry?.path();
133 if dir.is_dir() {
134 retain_newest(&dir, KEEP_PER_BUNDLE, "")?;
135 }
136 }
137 }
138 let composed = self.root.join("composed");
139 if composed.is_dir() {
140 retain_newest(&composed, KEEP_COMPOSED, keep_composed)?;
141 }
142 Ok(())
143 }
144}
145
146#[cfg(unix)]
147fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
148 std::os::unix::fs::symlink(target, link)
149}
150
151#[cfg(windows)]
152fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
153 std::os::windows::fs::symlink_dir(target, link)
154}
155
156fn retain_newest(dir: &Path, keep: usize, pinned: &str) -> BundleResult<()> {
157 let mut entries: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
158 for entry in fs::read_dir(dir)? {
159 let path = entry?.path();
160 if !path.is_dir() {
161 continue;
162 }
163 let modified = path
164 .metadata()
165 .and_then(|m| m.modified())
166 .unwrap_or(std::time::UNIX_EPOCH);
167 entries.push((modified, path));
168 }
169 entries.sort_by_key(|e| std::cmp::Reverse(e.0));
170
171 for (_time, path) in entries.into_iter().skip(keep) {
172 let is_pinned = path
173 .file_name()
174 .is_some_and(|n| !pinned.is_empty() && n == pinned);
175 if is_pinned {
176 continue;
177 }
178 if let Err(e) = fs::remove_dir_all(&path) {
179 tracing::warn!(path = %path.display(), error = %e, "Failed to prune cached bundle");
180 }
181 }
182 Ok(())
183}