1use std::path::{Path, PathBuf};
4
5use chrono::Utc;
6use diffy::{Patch, apply};
7
8use crate::backend_sync;
9use crate::errors::CoreError;
10use crate::repository_runtime::SqliteRuntime;
11use ito_common::id::parse_change_id;
12use ito_common::paths;
13use ito_domain::backend::{ArtifactBundle, BackendError, BackendProjectStore, BackendSyncClient};
14use ito_domain::changes::{
15 ChangeArtifactKind, ChangeArtifactMutationError, ChangeArtifactMutationResult,
16 ChangeArtifactMutationService, ChangeArtifactMutationServiceResult, ChangeArtifactRef,
17};
18
19use crate::change_repository::FsChangeRepository;
20use crate::sqlite_project_store::SqliteBackendProjectStore;
21
22#[derive(Debug, Clone)]
24pub struct FsChangeArtifactMutationService {
25 ito_path: PathBuf,
26}
27
28impl FsChangeArtifactMutationService {
29 pub fn new(ito_path: impl Into<PathBuf>) -> Self {
31 Self {
32 ito_path: ito_path.into(),
33 }
34 }
35}
36
37impl ChangeArtifactMutationService for FsChangeArtifactMutationService {
38 fn load_artifact(
39 &self,
40 target: &ChangeArtifactRef,
41 ) -> ChangeArtifactMutationServiceResult<Option<String>> {
42 let path = artifact_path(&self.ito_path, target)?;
43 if !path.is_file() {
44 return Ok(None);
45 }
46 let contents = ito_common::io::read_to_string_std(&path)
47 .map_err(|err| ChangeArtifactMutationError::io("reading change artifact", err))?;
48 Ok(Some(contents))
49 }
50
51 fn write_artifact(
52 &self,
53 target: &ChangeArtifactRef,
54 content: &str,
55 ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
56 ensure_change_exists(&self.ito_path, &target.change_id)?;
57 let path = artifact_path(&self.ito_path, target)?;
58 let existed = path.is_file();
59 if let Some(parent) = path.parent() {
60 std::fs::create_dir_all(parent).map_err(|err| {
61 ChangeArtifactMutationError::io("creating parent directories for artifact", err)
62 })?;
63 }
64 ito_common::io::write_std(&path, content)
65 .map_err(|err| ChangeArtifactMutationError::io("writing change artifact", err))?;
66 Ok(ChangeArtifactMutationResult {
67 target: target.clone(),
68 existed,
69 revision: None,
70 })
71 }
72
73 fn patch_artifact(
74 &self,
75 target: &ChangeArtifactRef,
76 patch: &str,
77 ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
78 ensure_change_exists(&self.ito_path, &target.change_id)?;
79 let path = artifact_path(&self.ito_path, target)?;
80 let Some(current) = self.load_artifact(target)? else {
81 return Err(ChangeArtifactMutationError::not_found(format!(
82 "Artifact '{}' not found for patching",
83 target.label()
84 )));
85 };
86 let updated = apply_unified_patch(¤t, patch, target)?;
87 if let Some(parent) = path.parent() {
88 std::fs::create_dir_all(parent).map_err(|err| {
89 ChangeArtifactMutationError::io("creating parent directories for artifact", err)
90 })?;
91 }
92 ito_common::io::write_std(&path, &updated)
93 .map_err(|err| ChangeArtifactMutationError::io("writing patched artifact", err))?;
94 Ok(ChangeArtifactMutationResult {
95 target: target.clone(),
96 existed: true,
97 revision: None,
98 })
99 }
100}
101
102#[derive(Debug, Clone)]
104pub struct BundleBackedChangeArtifactMutationService<C> {
105 client: C,
106}
107
108impl<C> BundleBackedChangeArtifactMutationService<C> {
109 pub fn new(client: C) -> Self {
111 Self { client }
112 }
113}
114
115impl<C> ChangeArtifactMutationService for BundleBackedChangeArtifactMutationService<C>
116where
117 C: ChangeArtifactBundleClient + Send + Sync,
118{
119 fn load_artifact(
120 &self,
121 target: &ChangeArtifactRef,
122 ) -> ChangeArtifactMutationServiceResult<Option<String>> {
123 let bundle = self.client.pull_bundle(&target.change_id)?;
124 Ok(bundle_artifact_content(&bundle, &target.artifact))
125 }
126
127 fn write_artifact(
128 &self,
129 target: &ChangeArtifactRef,
130 content: &str,
131 ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
132 let mut bundle = self.client.pull_bundle(&target.change_id)?;
133 let existed = bundle_artifact_content(&bundle, &target.artifact).is_some();
134 set_bundle_artifact(&mut bundle, &target.artifact, content.to_string());
135 let revision = self.client.push_bundle(&target.change_id, &bundle)?;
136 Ok(ChangeArtifactMutationResult {
137 target: target.clone(),
138 existed,
139 revision: Some(revision),
140 })
141 }
142
143 fn patch_artifact(
144 &self,
145 target: &ChangeArtifactRef,
146 patch: &str,
147 ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
148 let mut bundle = self.client.pull_bundle(&target.change_id)?;
149 let Some(current) = bundle_artifact_content(&bundle, &target.artifact) else {
150 return Err(ChangeArtifactMutationError::not_found(format!(
151 "Artifact '{}' not found for patching",
152 target.label()
153 )));
154 };
155 let updated = apply_unified_patch(¤t, patch, target)?;
156 set_bundle_artifact(&mut bundle, &target.artifact, updated);
157 let revision = self.client.push_bundle(&target.change_id, &bundle)?;
158 Ok(ChangeArtifactMutationResult {
159 target: target.clone(),
160 existed: true,
161 revision: Some(revision),
162 })
163 }
164}
165
166pub trait ChangeArtifactBundleClient: std::fmt::Debug + Clone {
168 fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle>;
170
171 fn push_bundle(
173 &self,
174 change_id: &str,
175 bundle: &ArtifactBundle,
176 ) -> ChangeArtifactMutationServiceResult<String>;
177}
178
179#[derive(Debug, Clone)]
181pub struct SqliteChangeArtifactBundleClient {
182 db_path: PathBuf,
183 org: String,
184 repo: String,
185}
186
187impl SqliteChangeArtifactBundleClient {
188 pub fn new(runtime: &SqliteRuntime) -> Self {
190 Self {
191 db_path: runtime.db_path.clone(),
192 org: runtime.org.clone(),
193 repo: runtime.repo.clone(),
194 }
195 }
196
197 fn open_store(&self) -> ChangeArtifactMutationServiceResult<SqliteBackendProjectStore> {
198 SqliteBackendProjectStore::open(&self.db_path).map_err(change_artifact_error_from_core)
199 }
200}
201
202impl ChangeArtifactBundleClient for SqliteChangeArtifactBundleClient {
203 fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
204 let store = self.open_store()?;
205 store
206 .pull_artifact_bundle(&self.org, &self.repo, change_id)
207 .map_err(change_artifact_error_from_backend)
208 }
209
210 fn push_bundle(
211 &self,
212 change_id: &str,
213 bundle: &ArtifactBundle,
214 ) -> ChangeArtifactMutationServiceResult<String> {
215 let store = self.open_store()?;
216 let result = store
217 .push_artifact_bundle(&self.org, &self.repo, change_id, bundle)
218 .map_err(change_artifact_error_from_backend)?;
219 Ok(result.new_revision)
220 }
221}
222
223#[derive(Debug, Clone)]
225pub struct FsChangeArtifactBundleClient {
226 ito_path: PathBuf,
227}
228
229impl FsChangeArtifactBundleClient {
230 pub fn new(ito_path: impl Into<PathBuf>) -> Self {
232 Self {
233 ito_path: ito_path.into(),
234 }
235 }
236}
237
238impl ChangeArtifactBundleClient for FsChangeArtifactBundleClient {
239 fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
240 backend_sync::read_local_bundle(&self.ito_path, change_id)
241 .map_err(change_artifact_error_from_core)
242 }
243
244 fn push_bundle(
245 &self,
246 change_id: &str,
247 bundle: &ArtifactBundle,
248 ) -> ChangeArtifactMutationServiceResult<String> {
249 let revision = Utc::now().to_rfc3339();
250 let mut next = bundle.clone();
251 next.change_id = change_id.to_string();
252 next.revision = revision.clone();
253 backend_sync::write_bundle_to_local(&self.ito_path, change_id, &next)
254 .map_err(change_artifact_error_from_core)?;
255 Ok(revision)
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct RemoteChangeArtifactBundleClient<S> {
262 client: S,
263}
264
265impl<S> RemoteChangeArtifactBundleClient<S> {
266 pub fn new(client: S) -> Self {
268 Self { client }
269 }
270}
271
272impl<S> ChangeArtifactBundleClient for RemoteChangeArtifactBundleClient<S>
273where
274 S: BackendSyncClient + Clone + std::fmt::Debug,
275{
276 fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
277 self.client
278 .pull(change_id)
279 .map_err(change_artifact_error_from_backend)
280 }
281
282 fn push_bundle(
283 &self,
284 change_id: &str,
285 bundle: &ArtifactBundle,
286 ) -> ChangeArtifactMutationServiceResult<String> {
287 let result = self
288 .client
289 .push(change_id, bundle)
290 .map_err(change_artifact_error_from_backend)?;
291 Ok(result.new_revision)
292 }
293}
294
295fn ensure_change_exists(
296 ito_path: &Path,
297 change_id: &str,
298) -> ChangeArtifactMutationServiceResult<()> {
299 if parse_change_id(change_id).is_err() {
300 return Err(ChangeArtifactMutationError::validation(format!(
301 "Invalid change id '{change_id}'"
302 )));
303 }
304 if !FsChangeRepository::new(ito_path).exists(change_id) {
305 return Err(ChangeArtifactMutationError::not_found(format!(
306 "Change '{change_id}' not found"
307 )));
308 }
309 Ok(())
310}
311
312fn artifact_path(
313 ito_path: &Path,
314 target: &ChangeArtifactRef,
315) -> ChangeArtifactMutationServiceResult<PathBuf> {
316 if parse_change_id(&target.change_id).is_err() {
317 return Err(ChangeArtifactMutationError::validation(format!(
318 "Invalid change id '{}'",
319 target.change_id
320 )));
321 }
322
323 let base = paths::change_dir(ito_path, &target.change_id);
324 match &target.artifact {
325 ChangeArtifactKind::Proposal => Ok(base.join("proposal.md")),
326 ChangeArtifactKind::Design => Ok(base.join("design.md")),
327 ChangeArtifactKind::Tasks => crate::tasks::tracking_file_path(ito_path, &target.change_id)
328 .map_err(change_artifact_error_from_core),
329 ChangeArtifactKind::SpecDelta { capability } => {
330 validate_path_component(capability, "capability")?;
331 Ok(paths::change_specs_dir(ito_path, &target.change_id)
332 .join(capability)
333 .join("spec.md"))
334 }
335 }
336}
337
338fn validate_path_component(value: &str, label: &str) -> ChangeArtifactMutationServiceResult<()> {
339 if value.trim().is_empty() {
340 return Err(ChangeArtifactMutationError::validation(format!(
341 "{label} must not be empty"
342 )));
343 }
344 if value.contains("..") || value.contains('/') || value.contains('\\') || value.contains('\0') {
345 return Err(ChangeArtifactMutationError::validation(format!(
346 "{label} contains unsafe path characters: {value:?}"
347 )));
348 }
349 Ok(())
350}
351
352fn apply_unified_patch(
353 current: &str,
354 patch_text: &str,
355 target: &ChangeArtifactRef,
356) -> ChangeArtifactMutationServiceResult<String> {
357 let patch = Patch::from_str(patch_text).map_err(|err| {
358 ChangeArtifactMutationError::validation(format!(
359 "Invalid patch for '{}': {err}",
360 target.label()
361 ))
362 })?;
363 apply(current, &patch).map_err(|err| {
364 ChangeArtifactMutationError::validation(format!(
365 "Patch did not apply cleanly for '{}': {err}",
366 target.label()
367 ))
368 })
369}
370
371fn bundle_artifact_content(
372 bundle: &ArtifactBundle,
373 artifact: &ChangeArtifactKind,
374) -> Option<String> {
375 match artifact {
376 ChangeArtifactKind::Proposal => bundle.proposal.clone(),
377 ChangeArtifactKind::Design => bundle.design.clone(),
378 ChangeArtifactKind::Tasks => bundle.tasks.clone(),
379 ChangeArtifactKind::SpecDelta { capability } => bundle
380 .specs
381 .iter()
382 .find(|(name, _)| name == capability)
383 .map(|(_, content)| content.clone()),
384 }
385}
386
387fn set_bundle_artifact(
388 bundle: &mut ArtifactBundle,
389 artifact: &ChangeArtifactKind,
390 content: String,
391) {
392 match artifact {
393 ChangeArtifactKind::Proposal => bundle.proposal = Some(content),
394 ChangeArtifactKind::Design => bundle.design = Some(content),
395 ChangeArtifactKind::Tasks => bundle.tasks = Some(content),
396 ChangeArtifactKind::SpecDelta { capability } => {
397 if let Some((_, existing)) =
398 bundle.specs.iter_mut().find(|(name, _)| name == capability)
399 {
400 *existing = content;
401 } else {
402 bundle.specs.push((capability.clone(), content));
403 bundle.specs.sort_by(|left, right| left.0.cmp(&right.0));
404 }
405 }
406 }
407}
408
409fn change_artifact_error_from_core(err: CoreError) -> ChangeArtifactMutationError {
410 match err {
411 CoreError::Domain(domain) => ChangeArtifactMutationError::other(domain.to_string()),
412 CoreError::Io { context, source } => ChangeArtifactMutationError::io(context, source),
413 CoreError::Validation(message) => ChangeArtifactMutationError::validation(message),
414 CoreError::Parse(message) => ChangeArtifactMutationError::validation(message),
415 CoreError::Process(message) => ChangeArtifactMutationError::other(message),
416 CoreError::Sqlite(message) => {
417 ChangeArtifactMutationError::other(format!("sqlite error: {message}"))
418 }
419 CoreError::NotFound(message) => ChangeArtifactMutationError::not_found(message),
420 CoreError::Serde { context, message } => {
421 ChangeArtifactMutationError::other(format!("{context}: {message}"))
422 }
423 }
424}
425
426fn change_artifact_error_from_backend(err: BackendError) -> ChangeArtifactMutationError {
427 match err {
428 BackendError::LeaseConflict(conflict) => ChangeArtifactMutationError::validation(format!(
429 "Lease conflict while mutating '{}': change is claimed by '{}'",
430 conflict.change_id, conflict.holder
431 )),
432 BackendError::RevisionConflict(conflict) => {
433 ChangeArtifactMutationError::validation(format!(
434 "Revision conflict for '{}': local revision '{}' is stale (server has '{}'). Retry after re-reading the current artifact state.",
435 conflict.change_id, conflict.local_revision, conflict.server_revision
436 ))
437 }
438 BackendError::Unavailable(message) => {
439 ChangeArtifactMutationError::other(format!("backend unavailable: {message}"))
440 }
441 BackendError::Unauthorized(message) => {
442 ChangeArtifactMutationError::validation(format!("backend auth failed: {message}"))
443 }
444 BackendError::NotFound(message) => {
445 ChangeArtifactMutationError::not_found(format!("backend resource not found: {message}"))
446 }
447 BackendError::Other(message) => ChangeArtifactMutationError::other(message),
448 }
449}
450
451#[cfg(test)]
452#[path = "artifact_mutations_tests.rs"]
453mod artifact_mutations_tests;