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)]
452mod tests {
453 use super::*;
454 use std::sync::{Arc, Mutex};
455 use tempfile::tempdir;
456
457 #[test]
458 fn fs_service_writes_and_patches_proposal() {
459 let tmp = tempdir().expect("tempdir");
460 let ito_path = tmp.path().join(".ito");
461 std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");
462
463 let service = FsChangeArtifactMutationService::new(&ito_path);
464 let target = ChangeArtifactRef {
465 change_id: "025-11_demo".to_string(),
466 artifact: ChangeArtifactKind::Proposal,
467 };
468
469 let result = service
470 .write_artifact(&target, "# Proposal\n")
471 .expect("write proposal");
472 assert!(!result.existed);
473 assert_eq!(
474 std::fs::read_to_string(
475 ito_path
476 .join("changes")
477 .join("025-11_demo")
478 .join("proposal.md")
479 )
480 .expect("read proposal"),
481 "# Proposal\n"
482 );
483
484 let patch = "--- proposal\n+++ proposal\n@@ -1 +1 @@\n-# Proposal\n+# Updated Proposal\n";
485 let result = service
486 .patch_artifact(&target, patch)
487 .expect("patch proposal");
488 assert!(result.existed);
489 assert_eq!(
490 std::fs::read_to_string(
491 ito_path
492 .join("changes")
493 .join("025-11_demo")
494 .join("proposal.md")
495 )
496 .expect("read patched proposal"),
497 "# Updated Proposal\n"
498 );
499 }
500
501 #[test]
502 fn fs_service_creates_spec_delta_directory_on_write() {
503 let tmp = tempdir().expect("tempdir");
504 let ito_path = tmp.path().join(".ito");
505 std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");
506
507 let service = FsChangeArtifactMutationService::new(&ito_path);
508 let target = ChangeArtifactRef {
509 change_id: "025-11_demo".to_string(),
510 artifact: ChangeArtifactKind::SpecDelta {
511 capability: "backend-agent-instructions".to_string(),
512 },
513 };
514
515 service
516 .write_artifact(&target, "## ADDED Requirements\n")
517 .expect("write spec delta");
518
519 let spec_path = ito_path
520 .join("changes")
521 .join("025-11_demo")
522 .join("specs")
523 .join("backend-agent-instructions")
524 .join("spec.md");
525 assert_eq!(
526 std::fs::read_to_string(spec_path).expect("read spec delta"),
527 "## ADDED Requirements\n"
528 );
529 }
530
531 #[derive(Debug, Clone)]
532 struct FakeBundleClient {
533 bundle: Arc<Mutex<ArtifactBundle>>,
534 revision: Arc<Mutex<u32>>,
535 }
536
537 impl ChangeArtifactBundleClient for FakeBundleClient {
538 fn pull_bundle(
539 &self,
540 _change_id: &str,
541 ) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
542 Ok(self.bundle.lock().expect("bundle lock").clone())
543 }
544
545 fn push_bundle(
546 &self,
547 _change_id: &str,
548 bundle: &ArtifactBundle,
549 ) -> ChangeArtifactMutationServiceResult<String> {
550 *self.bundle.lock().expect("bundle lock") = bundle.clone();
551 let mut revision = self.revision.lock().expect("revision lock");
552 *revision += 1;
553 Ok(format!("rev-{}", *revision))
554 }
555 }
556
557 #[test]
558 fn bundle_service_patches_design_and_returns_revision() {
559 let client = FakeBundleClient {
560 bundle: Arc::new(Mutex::new(ArtifactBundle {
561 change_id: "025-11_demo".to_string(),
562 proposal: Some("# Proposal\n".to_string()),
563 design: Some("# Design\n".to_string()),
564 tasks: None,
565 specs: vec![],
566 revision: "rev-1".to_string(),
567 })),
568 revision: Arc::new(Mutex::new(1)),
569 };
570
571 let service = BundleBackedChangeArtifactMutationService::new(client.clone());
572 let target = ChangeArtifactRef {
573 change_id: "025-11_demo".to_string(),
574 artifact: ChangeArtifactKind::Design,
575 };
576 let patch = "--- design\n+++ design\n@@ -1 +1 @@\n-# Design\n+# Updated Design\n";
577
578 let result = service
579 .patch_artifact(&target, patch)
580 .expect("patch design");
581 assert_eq!(result.revision.as_deref(), Some("rev-2"));
582 assert_eq!(
583 client.bundle.lock().expect("bundle lock").design.as_deref(),
584 Some("# Updated Design\n")
585 );
586 }
587}