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