1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
13#[serde(transparent)]
14pub struct RevisionId(pub String);
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[serde(transparent)]
19pub struct ArtifactId(pub String);
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields)]
24pub struct ArtifactInputDigest {
25 pub schema_version: u32,
27 pub digest: String,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum ObjectFormat {
35 Sha1,
37 Sha256,
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum SourceByteMode {
45 RawGitObjects,
47 CheckoutBytes,
49 DirtySnapshot,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
56pub enum RevisionSelector {
57 Live,
59 Dirty {
61 include_untracked: bool,
63 include_ignored: bool,
65 },
66 Ref {
68 name: String,
70 },
71 Commit {
73 oid: String,
75 },
76 Tree {
78 oid: String,
80 },
81 Worktree {
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 path: Option<PathBuf>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 worktree_id: Option<String>,
89 },
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct RepositoryIdentity {
96 pub repo_identity_hash: String,
98 pub object_format: ObjectFormat,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub remote_fingerprint: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct ResolvedRevision {
109 pub selector: RevisionSelector,
111 pub repository: RepositoryIdentity,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub commit_oid: Option<String>,
116 pub tree_oid: String,
118 pub object_format: ObjectFormat,
120 pub source_byte_mode: SourceByteMode,
122 pub resolved_at: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
129pub enum RevisionQueryTarget {
130 Live,
132 RevisionId {
134 revision_id: RevisionId,
136 },
137 Selector {
139 selector: RevisionSelector,
141 },
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(deny_unknown_fields)]
147pub struct RevisionQueryMetadata {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub revision_id: Option<RevisionId>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub artifact_id: Option<ArtifactId>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub resolved: Option<ResolvedRevision>,
157}
158
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162pub enum ResidentHandleKind {
163 LiveWorkspace,
165 ImmutableRevision,
167 DirtySnapshot,
169 ManagedWorktree,
171}
172
173#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum RevisionLoadState {
177 Loading,
179 Loaded,
181 Failed,
183 Evicting,
185 Unloaded,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(deny_unknown_fields)]
192pub struct RevisionStatus {
193 pub revision_id: RevisionId,
195 pub handle_kind: ResidentHandleKind,
197 pub resolved: ResolvedRevision,
199 pub artifact_id: ArtifactId,
201 pub artifact_inputs: ArtifactInputDigest,
203 pub state: RevisionLoadState,
205 pub pinned: bool,
207 pub active_queries: u64,
209 pub memory_bytes: u64,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub last_error: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(deny_unknown_fields)]
219pub struct LoadRevisionRequest {
220 pub root: PathBuf,
222 pub selector: RevisionSelector,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub source_byte_mode: Option<SourceByteMode>,
227 #[serde(default)]
229 pub pin: bool,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234#[serde(deny_unknown_fields)]
235pub struct LoadRevisionResult {
236 pub revision_id: RevisionId,
238 pub artifact_id: ArtifactId,
240 pub artifact_inputs: ArtifactInputDigest,
242 pub resolved: ResolvedRevision,
244 pub status: RevisionStatus,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(deny_unknown_fields)]
251pub struct UnloadRevisionRequest {
252 pub revision_id: RevisionId,
254 #[serde(default)]
256 pub force: bool,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(deny_unknown_fields)]
262pub struct UnloadRevisionResult {
263 pub revision_id: RevisionId,
265 pub unloaded: bool,
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(deny_unknown_fields)]
272pub struct ListRevisionsRequest {
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub root: Option<PathBuf>,
276 #[serde(default)]
278 pub include_unloaded: bool,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(deny_unknown_fields)]
284pub struct ListRevisionsResult {
285 pub revisions: Vec<RevisionStatus>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(deny_unknown_fields)]
292pub struct RevisionStatusRequest {
293 pub revision_id: RevisionId,
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
299#[serde(deny_unknown_fields)]
300pub struct PruneRevisionsRequest {
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub root: Option<PathBuf>,
304 #[serde(default)]
306 pub apply: bool,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311#[serde(deny_unknown_fields)]
312pub struct PruneRevisionCandidate {
313 pub revision_id: RevisionId,
315 pub artifact_id: ArtifactId,
317 pub reclaimable_bytes: u64,
319 pub reason: String,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325#[serde(deny_unknown_fields)]
326pub struct PruneWorktreeCandidate {
327 pub path: PathBuf,
329 pub reclaimable_bytes: u64,
331 pub locked: bool,
333 pub reason: String,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[serde(deny_unknown_fields)]
340pub struct PruneRefusal {
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub artifact_id: Option<ArtifactId>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub worktree_path: Option<PathBuf>,
347 pub reason: String,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(deny_unknown_fields)]
354pub struct PruneRevisionsResult {
355 pub candidates: Vec<PruneRevisionCandidate>,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub worktree_candidates: Vec<PruneWorktreeCandidate>,
360 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub refusals: Vec<PruneRefusal>,
363 pub applied: bool,
365 pub reclaimed_bytes: u64,
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn sample_repo() -> RepositoryIdentity {
374 RepositoryIdentity {
375 repo_identity_hash: "repo-hash".to_owned(),
376 object_format: ObjectFormat::Sha1,
377 remote_fingerprint: Some("remote:redacted".to_owned()),
378 }
379 }
380
381 fn sample_resolved(selector: RevisionSelector) -> ResolvedRevision {
382 ResolvedRevision {
383 selector,
384 repository: sample_repo(),
385 commit_oid: Some("1111111111111111111111111111111111111111".to_owned()),
386 tree_oid: "2222222222222222222222222222222222222222".to_owned(),
387 object_format: ObjectFormat::Sha1,
388 source_byte_mode: SourceByteMode::RawGitObjects,
389 resolved_at: "2026-06-26T00:00:00Z".to_owned(),
390 }
391 }
392
393 fn sample_status() -> RevisionStatus {
394 let resolved = sample_resolved(RevisionSelector::Ref {
395 name: "main".to_owned(),
396 });
397 RevisionStatus {
398 revision_id: RevisionId("rev-1".to_owned()),
399 handle_kind: ResidentHandleKind::ImmutableRevision,
400 resolved,
401 artifact_id: ArtifactId("artifact-1".to_owned()),
402 artifact_inputs: ArtifactInputDigest {
403 schema_version: 1,
404 digest: "digest-1".to_owned(),
405 },
406 state: RevisionLoadState::Loaded,
407 pinned: false,
408 active_queries: 0,
409 memory_bytes: 128,
410 last_error: None,
411 }
412 }
413
414 #[test]
415 fn revision_selector_round_trips_all_variants() {
416 let selectors = [
417 RevisionSelector::Live,
418 RevisionSelector::Dirty {
419 include_untracked: true,
420 include_ignored: false,
421 },
422 RevisionSelector::Ref {
423 name: "refs/heads/main".to_owned(),
424 },
425 RevisionSelector::Commit {
426 oid: "a".repeat(40),
427 },
428 RevisionSelector::Tree {
429 oid: "b".repeat(40),
430 },
431 RevisionSelector::Worktree {
432 path: Some(PathBuf::from("/repo-agent")),
433 worktree_id: Some("agent-1".to_owned()),
434 },
435 ];
436
437 for selector in selectors {
438 let wire = serde_json::to_string(&selector).expect("serialize selector");
439 let back: RevisionSelector = serde_json::from_str(&wire).expect("deserialize selector");
440 assert_eq!(back, selector);
441 }
442 }
443
444 #[test]
445 fn resolved_revision_round_trips_with_identity_and_source_mode() {
446 let resolved = sample_resolved(RevisionSelector::Commit {
447 oid: "a".repeat(40),
448 });
449 let wire = serde_json::to_string(&resolved).expect("serialize");
450 assert!(wire.contains(r#""source_byte_mode":"raw_git_objects""#));
451 assert!(wire.contains(r#""object_format":"sha1""#));
452 let back: ResolvedRevision = serde_json::from_str(&wire).expect("deserialize");
453 assert_eq!(back, resolved);
454 }
455
456 #[test]
457 fn artifact_id_is_opaque_but_inputs_are_manifest_verifiable() {
458 let id = ArtifactId("repo/artifact-key".to_owned());
459 let digest = ArtifactInputDigest {
460 schema_version: 1,
461 digest: "canonical-manifest-sha256".to_owned(),
462 };
463 let wire = serde_json::json!({
464 "artifact_id": id,
465 "artifact_inputs": digest,
466 });
467 assert_eq!(wire["artifact_id"], "repo/artifact-key");
468 assert_eq!(
469 wire["artifact_inputs"]["digest"],
470 "canonical-manifest-sha256"
471 );
472 }
473
474 #[test]
475 fn status_and_load_result_round_trip() {
476 let status = sample_status();
477 let result = LoadRevisionResult {
478 revision_id: status.revision_id.clone(),
479 artifact_id: status.artifact_id.clone(),
480 artifact_inputs: status.artifact_inputs.clone(),
481 resolved: status.resolved.clone(),
482 status,
483 };
484 let wire = serde_json::to_string(&result).expect("serialize");
485 let back: LoadRevisionResult = serde_json::from_str(&wire).expect("deserialize");
486 assert_eq!(back, result);
487 }
488
489 #[test]
490 fn list_status_and_prune_requests_round_trip() {
491 let list = ListRevisionsRequest {
492 root: Some(PathBuf::from("/repo")),
493 include_unloaded: true,
494 };
495 let wire = serde_json::to_string(&list).expect("serialize list");
496 let back: ListRevisionsRequest = serde_json::from_str(&wire).expect("deserialize list");
497 assert_eq!(back, list);
498
499 let prune = PruneRevisionsRequest {
500 root: Some(PathBuf::from("/repo")),
501 apply: false,
502 };
503 let wire = serde_json::to_string(&prune).expect("serialize prune");
504 let back: PruneRevisionsRequest = serde_json::from_str(&wire).expect("deserialize prune");
505 assert_eq!(back, prune);
506 }
507
508 #[test]
509 fn revision_query_target_round_trips_without_path_leak() {
510 let target = RevisionQueryTarget::RevisionId {
511 revision_id: RevisionId("rev-1".to_owned()),
512 };
513 let wire = serde_json::to_string(&target).expect("serialize");
514 assert!(!wire.contains("/"));
515 let back: RevisionQueryTarget = serde_json::from_str(&wire).expect("deserialize");
516 assert_eq!(back, target);
517 }
518}