Skip to main content

daemon/grpc_local_impl/
state_review.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local-mode implementation of the [`StateReviewService`] gRPC contract.
3//!
4//! Reads / writes the [`ReviewSignaturesBlob`] persisted at
5//! [`State::review_signatures`]. Verifies the client-supplied signature
6//! against the deterministic [`signing_payload`] before persisting.
7
8// `::state_review` disambiguates from this module's own name
9// (`grpc_local_impl::state_review`), the same way the hosted impl
10// disambiguates by being in a sibling module.
11use ::state_review::{
12    PathSymbol, ReadingOrderPartition, SymbolKind, build_review_payload_partition,
13};
14use crypto::verify_payload_signature;
15use grpc::heddle::v1::{
16    AnchoredDiscussion, GetReviewPayloadRequest, GetReviewProgressRequest,
17    GetReviewProgressResponse, ListSignaturesRequest, ListSignaturesResponse, MergeRequirement,
18    PathSymbolRef as ProtoPathSymbolRef, ReadingOrderPartition as ProtoReadingOrderPartition,
19    RecordCheckAckRequest, RecordCheckAckResponse, RecordVerdictRequest, RecordVerdictResponse,
20    ReviewPayload, ReviewScope as ProtoReviewScope, ReviewSignature as ProtoReviewSignature,
21    ReviewSummary, RiskSignal as ProtoRiskSignal, SignStateRequest, SignStateResponse,
22    SignalAnchor as ProtoSignalAnchor, SigningFooter,
23    state_review_service_server::StateReviewService,
24};
25use objects::{
26    lock::RepositoryLockExt,
27    object::{
28        Blob, ChangeId, DiffKind, Discussion, DiscussionResolution, DiscussionsBlob, ReviewKind,
29        ReviewScope, ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, State, SymbolAnchor,
30        signing_payload,
31    },
32    store::ObjectStore,
33    worktree::diff_blobs,
34};
35use prost::Message;
36use repo::Repository;
37use tonic::{Request, Response, Status};
38
39use super::{GrpcLocalService, to_status, with_idempotency};
40
41/// Maximum drift (seconds) between the client's `signed_at_unix` and the
42/// server's wall clock. Generous enough to absorb NTP skew, narrow enough
43/// to bound the window for replay-style attacks.
44const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
45
46/// Local-mode `StateReviewService` implementation.
47#[derive(Clone)]
48pub struct LocalStateReviewService {
49    inner: GrpcLocalService,
50}
51
52impl LocalStateReviewService {
53    pub fn new(inner: GrpcLocalService) -> Self {
54        Self { inner }
55    }
56}
57
58#[tonic::async_trait]
59impl StateReviewService for LocalStateReviewService {
60    async fn get_review_payload(
61        &self,
62        request: Request<GetReviewPayloadRequest>,
63    ) -> Result<Response<ReviewPayload>, Status> {
64        let req = request.into_inner();
65        let change_id = parse_change_id(&req.state_id)?;
66        let repo = self.inner.repo();
67        let state = repo
68            .store()
69            .get_state(&change_id)
70            .map_err(to_status)?
71            .ok_or_else(|| {
72                Status::not_found(format!("state {} not found", change_id.to_string_full()))
73            })?;
74
75        // Diff the state's tree against its first parent so the summary
76        // counts reflect what actually changed in this state. The
77        // signal registry / budgeter will eventually layer on top of
78        // this; until then `files_changed` is the most useful single
79        // number an agent can use for self-review.
80        let diff_summary = compute_state_diff_summary(repo, &state).map_err(to_status)?;
81
82        let summary = ReviewSummary {
83            headline: state.intent.clone().unwrap_or_default(),
84            files_changed: diff_summary.files_changed,
85            added_lines: diff_summary.added_lines,
86            removed_lines: diff_summary.removed_lines,
87            in_budget_signal_count: 0,
88            hidden_signal_count: 0,
89        };
90
91        let agent_narrative = if state.attribution.agent.is_some() {
92            state.intent.clone().unwrap_or_default()
93        } else {
94            String::new()
95        };
96
97        // Surface fired risk signals if requested. The signal registry will
98        // replace this with a proper budget split; until then everything we
99        // read is "visible".
100        let mut all_signals: Vec<ProtoRiskSignal> = Vec::new();
101        if req.include_all_signals
102            && let Some(hash) = state.risk_signals
103            && let Some(blob) = repo.store().get_blob(&hash).map_err(to_status)?
104        {
105            let decoded = RiskSignalBlob::decode(blob.content())
106                .map_err(|err| Status::internal(format!("decode risk signals: {err}")))?;
107            all_signals = decoded
108                .signals
109                .into_iter()
110                .map(|s| risk_signal_to_proto(s, "visible"))
111                .collect();
112        }
113
114        // Synthesize a structured `diff_summary` signal so the
115        // `in_budget_signals` array is non-empty even before the real
116        // signal registry is wired up. Anchored on each modified
117        // file (capped) so consumers can iterate without losing the
118        // summary aggregate. This is a deliberate stable shape: agents
119        // already iterating signals get a usable record per file
120        // change, and the registry-driven path will simply layer real
121        // signals alongside it.
122        let mut in_budget_signals: Vec<ProtoRiskSignal> = Vec::new();
123        let summary_kind = match (
124            diff_summary.added_files,
125            diff_summary.modified_files,
126            diff_summary.deleted_files,
127        ) {
128            (a, 0, 0) if a > 0 => "diff_summary.added_only",
129            (0, m, 0) if m > 0 => "diff_summary.modified_only",
130            (0, 0, d) if d > 0 => "diff_summary.deleted_only",
131            (0, 0, 0) => "diff_summary.empty",
132            _ => "diff_summary.mixed",
133        };
134        let summary_reason = format!(
135            "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
136            diff_summary.files_changed,
137            diff_summary.added_lines,
138            diff_summary.removed_lines,
139            diff_summary.added_files,
140            diff_summary.modified_files,
141            diff_summary.deleted_files,
142        );
143        // Per-file anchors keep the array reasoning-friendly when
144        // many files change, but cap so very large diffs don't bloat
145        // the payload. The aggregate summary always rides on the
146        // first entry's reason field; the rest carry per-file deltas.
147        const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
148        if diff_summary.changed_paths.is_empty() {
149            in_budget_signals.push(ProtoRiskSignal {
150                kind: summary_kind.to_string(),
151                anchor: Some(ProtoSignalAnchor {
152                    file: String::new(),
153                    symbol: String::new(),
154                    start_line: 0,
155                    end_line: 0,
156                }),
157                reason: summary_reason.clone(),
158                producer_module: "review_show.diff_summary".to_string(),
159                producer_version: 1,
160                computed_at: None,
161                visibility: "visible".to_string(),
162            });
163        } else {
164            for (idx, path_kind) in diff_summary
165                .changed_paths
166                .iter()
167                .take(MAX_DIFF_SIGNAL_ANCHORS)
168                .enumerate()
169            {
170                let reason = if idx == 0 {
171                    summary_reason.clone()
172                } else {
173                    format!("{} ({})", path_kind.path, path_kind.kind_str())
174                };
175                in_budget_signals.push(ProtoRiskSignal {
176                    kind: summary_kind.to_string(),
177                    anchor: Some(ProtoSignalAnchor {
178                        file: path_kind.path.clone(),
179                        symbol: String::new(),
180                        start_line: 0,
181                        end_line: 0,
182                    }),
183                    reason,
184                    producer_module: "review_show.diff_summary".to_string(),
185                    producer_version: 1,
186                    computed_at: None,
187                    visibility: "visible".to_string(),
188                });
189            }
190        }
191
192        // Server-side reading-order partition. Same per-symbol
193        // extraction logic as the hosted handler: tree-sitter when the
194        // `semantic` feature is enabled, path-only fallback otherwise.
195        let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
196            .map_err(to_status)?;
197        let partition = build_review_payload_partition(&symbols);
198
199        // Project the state's `DiscussionsBlob` (when present)
200        // into the wire-shape `AnchoredDiscussion` list.
201        let discussions = match state.discussions {
202            Some(hash) => {
203                let blob = repo
204                    .store()
205                    .get_blob(&hash)
206                    .map_err(to_status)?
207                    .ok_or_else(|| {
208                        Status::internal(format!(
209                            "discussions blob {} referenced by state {} is missing",
210                            hash,
211                            state.change_id.to_string_full()
212                        ))
213                    })?;
214                let decoded = DiscussionsBlob::decode(blob.content())
215                    .map_err(|err| Status::internal(format!("decode discussions: {err}")))?;
216                decoded
217                    .discussions
218                    .iter()
219                    .map(discussion_to_anchored_proto)
220                    .collect()
221            }
222            None => Vec::<AnchoredDiscussion>::new(),
223        };
224
225        let mut summary = summary;
226        summary.in_budget_signal_count = in_budget_signals.len() as u32;
227        summary.hidden_signal_count =
228            all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
229
230        let payload = ReviewPayload {
231            state_id: req.state_id.clone(),
232            summary: Some(summary),
233            agent_narrative,
234            partition: Some(partition_to_proto(partition)),
235            in_budget_signals,
236            all_signals,
237            tick_budget: 3,
238            discussions,
239            // Local mode has no policy registry — merge requirements
240            // are surfaced only by the hosted handler.
241            merge_requirements: Vec::<MergeRequirement>::new(),
242            signing_footer: Some(SigningFooter {
243                available_kinds: vec![
244                    grpc::heddle::v1::ReviewKind::Read as i32,
245                    grpc::heddle::v1::ReviewKind::AgentPreview as i32,
246                    grpc::heddle::v1::ReviewKind::AgentCoReview as i32,
247                ],
248            }),
249        };
250
251        Ok(Response::new(payload))
252    }
253
254    async fn sign_state(
255        &self,
256        request: Request<SignStateRequest>,
257    ) -> Result<Response<SignStateResponse>, Status> {
258        let req = request.into_inner();
259        let req_bytes = req.encode_to_vec();
260        let client_operation_id = req.client_operation_id.clone();
261        let inner = self.inner.clone();
262
263        let response = with_idempotency(
264            &self.inner,
265            &client_operation_id,
266            "state_review.sign_state",
267            &req_bytes,
268            move || {
269                let inner = inner.clone();
270                async move { execute_sign_state(&inner, req).await }
271            },
272        )
273        .await?;
274
275        Ok(Response::new(response))
276    }
277
278    async fn list_signatures(
279        &self,
280        request: Request<ListSignaturesRequest>,
281    ) -> Result<Response<ListSignaturesResponse>, Status> {
282        let req = request.into_inner();
283        let change_id = parse_change_id(&req.state_id)?;
284        let repo = self.inner.repo();
285        let state = repo
286            .store()
287            .get_state(&change_id)
288            .map_err(to_status)?
289            .ok_or_else(|| {
290                Status::not_found(format!("state {} not found", change_id.to_string_full()))
291            })?;
292
293        let signatures = match state.review_signatures {
294            Some(hash) => {
295                let blob = repo
296                    .store()
297                    .get_blob(&hash)
298                    .map_err(to_status)?
299                    .ok_or_else(|| {
300                        Status::internal(format!(
301                            "review signatures blob {} missing from object store",
302                            hash
303                        ))
304                    })?;
305                let decoded = ReviewSignaturesBlob::decode(blob.content())
306                    .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?;
307                decoded
308                    .signatures
309                    .into_iter()
310                    .enumerate()
311                    .map(|(idx, sig)| review_signature_to_proto(sig, synthetic_signature_id(idx)))
312                    .collect()
313            }
314            None => Vec::new(),
315        };
316
317        Ok(Response::new(ListSignaturesResponse { signatures }))
318    }
319
320    // The verdict (weft#481) and review-progress (weft#482) RPCs are defined
321    // on the wire in heddle-grpc 0.19 but implemented server-side in weft
322    // (the verdict blob append + the `review_check_acks` table). Local mode
323    // does not back these; the handlers land with the weft impl.
324    async fn record_verdict(
325        &self,
326        _request: Request<RecordVerdictRequest>,
327    ) -> Result<Response<RecordVerdictResponse>, Status> {
328        Err(Status::unimplemented(
329            "RecordVerdict is not available in local mode (weft#481)",
330        ))
331    }
332
333    async fn record_check_ack(
334        &self,
335        _request: Request<RecordCheckAckRequest>,
336    ) -> Result<Response<RecordCheckAckResponse>, Status> {
337        Err(Status::unimplemented(
338            "RecordCheckAck is not available in local mode (weft#482)",
339        ))
340    }
341
342    async fn get_review_progress(
343        &self,
344        _request: Request<GetReviewProgressRequest>,
345    ) -> Result<Response<GetReviewProgressResponse>, Status> {
346        Err(Status::unimplemented(
347            "GetReviewProgress is not available in local mode (weft#482)",
348        ))
349    }
350}
351
352/// Body of [`LocalStateReviewService::sign_state`]. Lifted out of the trait
353/// method so [`with_idempotency`] can re-execute it inside its closure.
354async fn execute_sign_state(
355    inner: &GrpcLocalService,
356    req: SignStateRequest,
357) -> Result<SignStateResponse, Status> {
358    // 1. Validate the kind.
359    let kind = match grpc::heddle::v1::ReviewKind::try_from(req.kind)
360        .map_err(|_| Status::invalid_argument(format!("unknown review kind tag {}", req.kind)))?
361    {
362        grpc::heddle::v1::ReviewKind::Read => ReviewKind::Read,
363        grpc::heddle::v1::ReviewKind::AgentPreview => ReviewKind::AgentPreview,
364        grpc::heddle::v1::ReviewKind::AgentCoReview => ReviewKind::AgentCoReview,
365        grpc::heddle::v1::ReviewKind::Unspecified => {
366            return Err(Status::invalid_argument("review kind is required"));
367        }
368    };
369
370    // 2. Locate the state.
371    let change_id = parse_change_id(&req.state_id)?;
372    let repo = inner.repo();
373
374    // 3. Translate the scope.
375    let scope = match req.scope.as_ref() {
376        Some(s) => proto_scope_to_object(s)?,
377        None => ReviewScope::WholeChange,
378    };
379
380    // 4. Build the ReviewSignature, then verify the client-supplied
381    // signature is well-formed and matches the deterministic signing
382    // payload. A malformed or forged signature must never reach the
383    // persisted blob. Attribute the signature to the local-mode
384    // caller (`Repository::get_principal` resolves env vars then
385    // `[principal]` in `.heddle/config.toml`), not the state's author
386    // — Bob signing Alice's state should record Bob.
387    let actor = repo
388        .get_principal()
389        .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?;
390    let justification = if req.justification.is_empty() {
391        None
392    } else {
393        Some(req.justification.clone())
394    };
395
396    let now = chrono::Utc::now().timestamp();
397    let signed_at = req.signed_at.as_ref().map(|t| t.seconds).unwrap_or(0);
398    if signed_at == 0 {
399        return Err(Status::invalid_argument(
400            "signed_at is required and must match the timestamp the client signed over",
401        ));
402    }
403    if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
404        return Err(Status::invalid_argument(format!(
405            "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
406        )));
407    }
408
409    let new_sig = ReviewSignature {
410        actor,
411        kind,
412        scope: scope.clone(),
413        justification: justification.clone(),
414        signed_at,
415        algorithm: req.algorithm.clone(),
416        public_key: hex::encode(&req.public_key),
417        signature: hex::encode(&req.signature),
418    };
419    new_sig
420        .validate()
421        .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
422
423    let public_key_bytes = req.public_key.clone();
424    let signature_bytes = req.signature.clone();
425    let payload = signing_payload(change_id, kind, &scope, signed_at, justification.as_deref());
426    verify_payload_signature(
427        &payload,
428        &req.algorithm,
429        &public_key_bytes,
430        &signature_bytes,
431    )
432    .map_err(|err| {
433        Status::invalid_argument(format!(
434            "review signature failed verification ({}): {err}",
435            req.algorithm
436        ))
437    })?;
438
439    // 5. Serialize the read-modify-write on `review_signatures`
440    // behind the repo write-lock and re-load the state inside the
441    // critical section. Two concurrent SignStates with different
442    // operation ids would otherwise both read the same base blob and
443    // the second `put_state` would clobber the first signature.
444    let _lock = repo
445        .locker()
446        .write()
447        .map_err(|err| Status::internal(err.to_string()))?;
448    let state = repo
449        .store()
450        .get_state(&change_id)
451        .map_err(to_status)?
452        .ok_or_else(|| {
453            Status::not_found(format!("state {} not found", change_id.to_string_full()))
454        })?;
455    let mut blob = match state.review_signatures {
456        Some(hash) => {
457            let raw = repo
458                .store()
459                .get_blob(&hash)
460                .map_err(to_status)?
461                .ok_or_else(|| {
462                    Status::internal(format!(
463                        "existing review signatures blob {} missing from object store",
464                        hash
465                    ))
466                })?;
467            ReviewSignaturesBlob::decode(raw.content())
468                .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?
469        }
470        None => ReviewSignaturesBlob::new(Vec::new()),
471    };
472    blob.signatures.push(new_sig);
473    let new_index = blob.signatures.len() - 1;
474
475    // 6. Persist the new blob.
476    let bytes = blob
477        .encode()
478        .map_err(|err| Status::internal(format!("encode review signatures: {err}")))?;
479    let content_hash = repo
480        .store()
481        .put_blob(&Blob::new(bytes))
482        .map_err(to_status)?;
483
484    // 7. Persist the updated state.
485    let new_state = state.with_review_signatures(content_hash);
486    repo.store().put_state(&new_state).map_err(to_status)?;
487
488    Ok(SignStateResponse {
489        signature_id: synthetic_signature_id(new_index),
490        state_id: req.state_id,
491    })
492}
493
494/// `ReviewSignature` doesn't carry an explicit id; we synthesise one from
495/// the per-state index so the wire surface has stable signature ids within a
496/// single state. (A future schema bump may add an explicit id.)
497fn synthetic_signature_id(index: usize) -> String {
498    format!("rs-{index}")
499}
500
501fn parse_change_id(s: &[u8]) -> Result<ChangeId, Status> {
502    ChangeId::try_from_slice(s)
503        .map_err(|err| Status::invalid_argument(format!("invalid state_id: {err}")))
504}
505
506fn proto_scope_to_object(scope: &ProtoReviewScope) -> Result<ReviewScope, Status> {
507    use grpc::heddle::v1::review_scope::Scope;
508    match scope.scope.as_ref() {
509        None | Some(Scope::WholeChange(_)) => Ok(ReviewScope::WholeChange),
510        Some(Scope::Symbols(list)) => {
511            if list.symbols.is_empty() {
512                return Err(Status::invalid_argument(
513                    "symbols scope requires at least one symbol anchor",
514                ));
515            }
516            let symbols = list
517                .symbols
518                .iter()
519                .map(|s| SymbolAnchor::new(s.file.clone(), s.symbol.clone()))
520                .collect();
521            Ok(ReviewScope::Symbols(symbols))
522        }
523    }
524}
525
526fn object_scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
527    use grpc::heddle::v1::review_scope::{Scope, SymbolList, WholeChange};
528    let inner = match scope {
529        ReviewScope::WholeChange => Scope::WholeChange(WholeChange {}),
530        ReviewScope::Symbols(symbols) => Scope::Symbols(SymbolList {
531            symbols: symbols
532                .iter()
533                .map(|s| ProtoPathSymbolRef {
534                    file: s.file.clone(),
535                    symbol: s.symbol.clone(),
536                })
537                .collect(),
538        }),
539    };
540    ProtoReviewScope { scope: Some(inner) }
541}
542
543fn review_signature_to_proto(sig: ReviewSignature, signature_id: String) -> ProtoReviewSignature {
544    ProtoReviewSignature {
545        signature_id,
546        actor_name: sig.actor.name.clone(),
547        actor_email: sig.actor.email.clone(),
548        kind: review_kind_to_proto(sig.kind) as i32,
549        scope: Some(object_scope_to_proto(&sig.scope)),
550        justification: sig.justification.unwrap_or_default(),
551        signed_at: Some(prost_types::Timestamp {
552            seconds: sig.signed_at,
553            nanos: 0,
554        }),
555        algorithm: sig.algorithm,
556        public_key: hex::decode(&sig.public_key).unwrap_or_default(),
557        signature: hex::decode(&sig.signature).unwrap_or_default(),
558        // Legacy positive signatures predate the verdict surface (weft#481):
559        // VERDICT_UNSPECIFIED (treated as SIGN) with no decline reason.
560        verdict: grpc::heddle::v1::Verdict::Unspecified as i32,
561        reason: String::new(),
562    }
563}
564
565fn review_kind_to_proto(kind: ReviewKind) -> grpc::heddle::v1::ReviewKind {
566    match kind {
567        ReviewKind::Read => grpc::heddle::v1::ReviewKind::Read,
568        ReviewKind::AgentPreview => grpc::heddle::v1::ReviewKind::AgentPreview,
569        ReviewKind::AgentCoReview => grpc::heddle::v1::ReviewKind::AgentCoReview,
570    }
571}
572
573fn risk_signal_to_proto(sig: objects::object::RiskSignal, visibility: &str) -> ProtoRiskSignal {
574    let (start_line, end_line) = sig.anchor.line_range.unwrap_or((0, 0));
575    ProtoRiskSignal {
576        kind: sig.kind.as_str().to_string(),
577        anchor: Some(ProtoSignalAnchor {
578            file: sig.anchor.file,
579            symbol: sig.anchor.symbol.unwrap_or_default(),
580            start_line,
581            end_line,
582        }),
583        reason: sig.reason,
584        producer_module: sig.producer.module,
585        producer_version: sig.producer.version,
586        computed_at: Some(prost_types::Timestamp {
587            seconds: sig.computed_at,
588            nanos: 0,
589        }),
590        visibility: visibility.to_string(),
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Symbol extraction + discussion projection (mirrors hosted impl).
596// ---------------------------------------------------------------------------
597
598/// Symbol projection for the reading-order partition. Mirrors the hosted-handler
599/// implementation: when the `semantic` feature is enabled and the
600/// changed path has a tree-sitter parser and a readable new-side blob, emits
601/// one [`PathSymbol`] per definition. Otherwise falls back to a single path-only
602/// entry (kind = `Other`), which keeps deletes and gitlink pointer changes
603/// visible even though they do not carry Heddle blob content.
604fn changed_files_as_symbols(
605    repo: &Repository,
606    state: &State,
607    changed_paths: &[ChangedPath],
608) -> objects::error::Result<Vec<PathSymbol>> {
609    let new_tree = match repo.store().get_tree(&state.tree)? {
610        Some(t) => t,
611        None => return Ok(Vec::new()),
612    };
613    let new_files = collect_files(repo, &new_tree, "")?;
614
615    let mut out: Vec<PathSymbol> = Vec::new();
616    for path_kind in changed_paths {
617        let path = &path_kind.path;
618        #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
619        let mut emitted_any = false;
620        if let Some(hash) = new_files.get(path) {
621            #[cfg(feature = "semantic")]
622            {
623                if let Some(blob) = repo.store().get_blob(hash)? {
624                    emitted_any = extract_file_symbols(path, blob.content(), &mut out);
625                }
626            }
627            #[cfg(not(feature = "semantic"))]
628            {
629                let _ = hash;
630            }
631        }
632        if !emitted_any {
633            out.push(PathSymbol {
634                file: path.clone(),
635                symbol: path.clone(),
636                kind: SymbolKind::Other,
637            });
638        }
639    }
640    Ok(out)
641}
642
643#[cfg(feature = "semantic")]
644fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
645    use ::semantic::symbol_resolver::{Definition, DefinitionKind, extract_definitions};
646    let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
647    {
648        Ok(defs) => defs,
649        Err(_) => return false,
650    };
651    if definitions.is_empty() {
652        return false;
653    }
654    for d in definitions {
655        let kind = match d.kind {
656            DefinitionKind::Type => SymbolKind::Type,
657            DefinitionKind::Trait => SymbolKind::Trait,
658            DefinitionKind::Class => SymbolKind::Class,
659            DefinitionKind::Interface => SymbolKind::Interface,
660            DefinitionKind::TypeAlias => SymbolKind::TypeAlias,
661            DefinitionKind::EnumDef => SymbolKind::EnumDef,
662            DefinitionKind::ConstDecl => SymbolKind::ConstDecl,
663            DefinitionKind::Module => SymbolKind::Module,
664            DefinitionKind::Function => SymbolKind::Function,
665            DefinitionKind::Other => SymbolKind::Other,
666        };
667        let symbol = match d.parent_name.as_deref() {
668            Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
669            _ => d.name,
670        };
671        out.push(PathSymbol {
672            file: path.to_string(),
673            symbol,
674            kind,
675        });
676    }
677    true
678}
679
680fn collect_files(
681    repo: &Repository,
682    tree: &objects::object::Tree,
683    prefix: &str,
684) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
685    let mut out = std::collections::HashMap::new();
686    for entry in tree.entries() {
687        let path = if prefix.is_empty() {
688            entry.name().to_string()
689        } else {
690            format!("{prefix}/{}", entry.name())
691        };
692        if entry.is_tree() {
693            if let Some(hash) = entry.tree_hash()
694                && let Some(subtree) = repo.store().get_tree(&hash)?
695            {
696                let sub = collect_files(repo, &subtree, &path)?;
697                out.extend(sub);
698            }
699        } else if let Some(hash) = entry.content_hash() {
700            out.insert(path, hash);
701        }
702    }
703    Ok(out)
704}
705
706fn partition_to_proto(p: ReadingOrderPartition) -> ProtoReadingOrderPartition {
707    ProtoReadingOrderPartition {
708        structural: p.structural.iter().map(path_symbol_to_proto).collect(),
709        consequence: p.consequence.iter().map(path_symbol_to_proto).collect(),
710        tests_and_docs: p.tests_and_docs.iter().map(path_symbol_to_proto).collect(),
711    }
712}
713
714fn path_symbol_to_proto(p: &PathSymbol) -> ProtoPathSymbolRef {
715    ProtoPathSymbolRef {
716        file: p.file.clone(),
717        symbol: p.symbol.clone(),
718    }
719}
720
721fn discussion_to_anchored_proto(d: &Discussion) -> AnchoredDiscussion {
722    AnchoredDiscussion {
723        id: d.id.clone(),
724        anchor: Some(ProtoPathSymbolRef {
725            file: d.anchor.file.clone(),
726            symbol: d.anchor.symbol.clone(),
727        }),
728        opened_against_state: d.opened_against_state.as_bytes().to_vec(),
729        opened_at: Some(prost_types::Timestamp {
730            seconds: d.opened_at,
731            nanos: 0,
732        }),
733        turns: d
734            .turns
735            .iter()
736            .map(|t| grpc::heddle::v1::DiscussionTurn {
737                author_name: t.author.name.clone(),
738                author_email: t.author.email.clone(),
739                body: t.body.clone(),
740                posted_at: Some(prost_types::Timestamp {
741                    seconds: t.posted_at,
742                    nanos: 0,
743                }),
744            })
745            .collect(),
746        resolution: Some(discussion_resolution_to_proto(&d.resolution)),
747        body_changed_since_open: d.body_changed_since_open,
748        orphaned: d.orphaned,
749        visibility: d.visibility.as_str().to_string(),
750    }
751}
752
753fn discussion_resolution_to_proto(
754    resolution: &DiscussionResolution,
755) -> grpc::heddle::v1::DiscussionResolution {
756    use grpc::heddle::v1::discussion_resolution::{
757        Dismissed, Open, ResolvedByEdit, ResolvedIntoAnnotation, State,
758    };
759    let state = match resolution {
760        DiscussionResolution::Open => State::Open(Open {}),
761        DiscussionResolution::ResolvedIntoAnnotation { annotation_id } => {
762            State::IntoAnnotation(ResolvedIntoAnnotation {
763                annotation_id: annotation_id.clone(),
764            })
765        }
766        DiscussionResolution::ResolvedByEdit { state_id } => State::ByEdit(ResolvedByEdit {
767            state_id: state_id.as_bytes().to_vec(),
768        }),
769        DiscussionResolution::Dismissed { reason } => State::Dismissed(Dismissed {
770            reason: reason.clone(),
771        }),
772    };
773    grpc::heddle::v1::DiscussionResolution { state: Some(state) }
774}
775
776// ---------------------------------------------------------------------------
777// Diff summary helpers (state.tree vs first parent's tree).
778// ---------------------------------------------------------------------------
779
780/// File-change kinds we surface in the diff summary signal anchors.
781/// Mirrors `objects::object::DiffKind` minus the `Unchanged` variant
782/// (we filter those out before constructing this).
783#[derive(Debug, Clone)]
784struct ChangedPath {
785    path: String,
786    kind: DiffKind,
787}
788
789impl ChangedPath {
790    fn kind_str(&self) -> &'static str {
791        match self.kind {
792            DiffKind::Added => "added",
793            DiffKind::Modified => "modified",
794            DiffKind::Deleted => "deleted",
795            DiffKind::Unchanged => "unchanged",
796        }
797    }
798}
799
800/// Aggregated counts plus a path list, computed by diffing
801/// `state.tree` against the first parent's tree (or empty when the
802/// state is a root). When `state.parents` is empty every file in the
803/// state's tree counts as added, which makes "first capture" reviews
804/// non-empty too. The `_state` prefix on `_state` is intentional: the
805/// helper currently only reads `state.tree` and `state.parents`.
806struct DiffSummary {
807    files_changed: u32,
808    added_files: u32,
809    modified_files: u32,
810    deleted_files: u32,
811    added_lines: u32,
812    removed_lines: u32,
813    changed_paths: Vec<ChangedPath>,
814}
815
816/// Compute a summary diff for `state` vs its first parent. Errors
817/// from the object store propagate; missing trees / blobs are skipped
818/// silently (treated as zero-change for that path) so a partially
819/// pruned object store never blocks the review surface. The
820/// distinction matters: missing-object errors must become zero (the
821/// summary is best-effort, callers want a payload they can render),
822/// but genuine I/O errors must still propagate so a corrupt store
823/// surfaces loudly instead of silently truncating the review.
824fn compute_state_diff_summary(
825    repo: &Repository,
826    state: &State,
827) -> objects::error::Result<DiffSummary> {
828    use objects::object::Tree;
829    let parent_tree_hash = if let Some(parent_id) = state.parents.first() {
830        match repo.store().get_state(parent_id)? {
831            Some(parent_state) => parent_state.tree,
832            None => Tree::new().hash(),
833        }
834    } else {
835        Tree::new().hash()
836    };
837
838    // Resolve both tree objects up front so the missing-tree case
839    // becomes a synthesized empty changeset rather than an error from
840    // the recursive diff. `get_tree` returns `Ok(None)` for missing
841    // (not an error), and propagates only on genuine I/O — matching
842    // the policy the doc-comment claims.
843    let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
844    let new_tree_obj = repo.store().get_tree(&state.tree)?;
845
846    // If either tree is missing from the local store the diff is not
847    // meaningful — return an empty summary instead of erroring out.
848    // This mirrors the "Modified branch tolerates missing blobs" stance
849    // for the *tree* level: a partially pruned store should never block
850    // review payload retrieval, only render an empty summary.
851    let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
852        repo.diff_trees(&parent_tree_hash, &state.tree)?
853    } else {
854        objects::object::FileChangeSet::new()
855    };
856
857    // Compute per-file line deltas. We only count `Modified` here for
858    // the symmetric add/remove totals; `Added` files contribute every
859    // line as an add, and `Deleted` files contribute every line as a
860    // remove. Files with non-utf8 contents (e.g. binaries) silently
861    // contribute zero — `diff_blobs` already returns an empty vec in
862    // that case, and we mirror the same behavior for raw line counts.
863    let mut added_lines: u32 = 0;
864    let mut removed_lines: u32 = 0;
865    let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
866
867    let parent_files = match parent_tree_obj.as_ref() {
868        Some(t) => collect_files(repo, t, "")?,
869        None => std::collections::HashMap::new(),
870    };
871    let new_files = match new_tree_obj.as_ref() {
872        Some(t) => collect_files(repo, t, "")?,
873        None => std::collections::HashMap::new(),
874    };
875
876    let mut added_files: u32 = 0;
877    let mut modified_files: u32 = 0;
878    let mut deleted_files: u32 = 0;
879
880    for change in changes.iter() {
881        match change.kind {
882            DiffKind::Added => {
883                added_files += 1;
884                // Missing blob (`get_blob` returns `Ok(None)`) → file
885                // counts but contributes zero lines. Genuine I/O
886                // errors still propagate via `?` — same shape as the
887                // Modified branch's intent, but here we keep the
888                // distinction explicit so a corrupt store surfaces
889                // rather than getting silently swallowed.
890                if let Some(hash) = new_files.get(&change.path)
891                    && let Some(blob) = repo.store().get_blob(hash)?
892                {
893                    added_lines = added_lines.saturating_add(line_count(blob.content()));
894                }
895            }
896            DiffKind::Deleted => {
897                deleted_files += 1;
898                if let Some(hash) = parent_files.get(&change.path)
899                    && let Some(blob) = repo.store().get_blob(hash)?
900                {
901                    removed_lines = removed_lines.saturating_add(line_count(blob.content()));
902                }
903            }
904            DiffKind::Modified => {
905                modified_files += 1;
906                // `get_blob` already returns `Ok(None)` for a missing
907                // blob, so `?` here only fires on genuine I/O. Match
908                // the Added/Deleted branches' propagation policy
909                // explicitly instead of the older `.ok().flatten()`
910                // form, which silently swallowed IO errors and
911                // conflated them with "missing".
912                let old_blob = match parent_files.get(&change.path) {
913                    Some(h) => repo.store().get_blob(h)?,
914                    None => None,
915                };
916                let new_blob = match new_files.get(&change.path) {
917                    Some(h) => repo.store().get_blob(h)?,
918                    None => None,
919                };
920                if let (Some(old), Some(new)) = (old_blob, new_blob) {
921                    for line in diff_blobs(&old, &new) {
922                        match line {
923                            objects::worktree::DiffLine::Added(_) => {
924                                added_lines = added_lines.saturating_add(1);
925                            }
926                            objects::worktree::DiffLine::Removed(_) => {
927                                removed_lines = removed_lines.saturating_add(1);
928                            }
929                            objects::worktree::DiffLine::Context(_) => {}
930                        }
931                    }
932                }
933            }
934            DiffKind::Unchanged => continue,
935        }
936        changed_paths.push(ChangedPath {
937            path: change.path.clone(),
938            kind: change.kind,
939        });
940    }
941
942    Ok(DiffSummary {
943        files_changed: changed_paths.len() as u32,
944        added_files,
945        modified_files,
946        deleted_files,
947        added_lines,
948        removed_lines,
949        changed_paths,
950    })
951}
952
953/// Count the number of newline-separated lines in a file blob. Binary
954/// blobs (non-utf8) count as zero — we deliberately don't byte-count
955/// them, since "lines" is meaningless for binary content. A trailing
956/// newline does not introduce a phantom empty line.
957fn line_count(content: &[u8]) -> u32 {
958    let Ok(s) = std::str::from_utf8(content) else {
959        return 0;
960    };
961    if s.is_empty() {
962        return 0;
963    }
964    let trimmed = s.strip_suffix('\n').unwrap_or(s);
965    if trimmed.is_empty() {
966        return 1;
967    }
968    (trimmed.matches('\n').count() as u32).saturating_add(1)
969}
970
971// ---------------------------------------------------------------------------
972// Tests
973// ---------------------------------------------------------------------------
974
975#[cfg(test)]
976mod tests {
977    use std::sync::Arc;
978
979    use crypto::Signer as _;
980    use grpc::heddle::v1::ReviewScope as ProtoReviewScope;
981    use repo::{Repository, operation_dedup::OperationDedupStore};
982    use tempfile::TempDir;
983
984    use super::*;
985
986    fn fresh_service() -> (LocalStateReviewService, Arc<Repository>, TempDir) {
987        let temp = TempDir::new().expect("create tempdir");
988        // SAFETY: tests run with a controlled environment; setting these
989        // env vars steers the default attribution into a predictable place.
990        // SAFETY: setting env vars in tests; rust 2024 marks these unsafe.
991        unsafe {
992            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
993            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
994        }
995        let repo = Repository::init_default(temp.path()).expect("init repo");
996        let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
997        let repo = Arc::new(repo);
998        let svc =
999            LocalStateReviewService::new(GrpcLocalService::new(repo.clone(), Arc::new(dedup)));
1000        (svc, repo, temp)
1001    }
1002
1003    fn capture_state(repo: &Repository) -> ChangeId {
1004        // Write a tiny file so snapshot has something to capture.
1005        std::fs::write(repo.root().join("hello.txt"), b"hi").expect("write file");
1006        let state = repo
1007            .snapshot(Some("seed".to_string()), None)
1008            .expect("snapshot");
1009        state.change_id
1010    }
1011
1012    fn sign_request(state_id: &ChangeId, op_id: &str) -> SignStateRequest {
1013        let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1014        let scope = ReviewScope::WholeChange;
1015        let signed_at = chrono::Utc::now().timestamp();
1016        let payload = signing_payload(*state_id, ReviewKind::Read, &scope, signed_at, None);
1017        let signature = signer.sign(&payload).expect("sign payload");
1018        use grpc::heddle::v1::review_scope::{Scope, WholeChange};
1019        SignStateRequest {
1020            repo_path: String::new(),
1021            state_id: state_id.as_bytes().to_vec(),
1022            kind: grpc::heddle::v1::ReviewKind::Read as i32,
1023            scope: Some(ProtoReviewScope {
1024                scope: Some(Scope::WholeChange(WholeChange {})),
1025            }),
1026            justification: String::new(),
1027            algorithm: "ed25519".to_string(),
1028            public_key: signer.public_key().to_vec(),
1029            signature: signature.clone(),
1030            signed_at: Some(prost_types::Timestamp {
1031                seconds: signed_at,
1032                nanos: 0,
1033            }),
1034            client_operation_id: op_id.to_string(),
1035        }
1036    }
1037
1038    #[tokio::test]
1039    #[serial_test::serial(process_global)]
1040    async fn sign_state_persists_to_review_signatures_blob() {
1041        let (svc, repo, _tmp) = fresh_service();
1042        let state_id = capture_state(&repo);
1043
1044        let resp = svc
1045            .sign_state(Request::new(sign_request(&state_id, "")))
1046            .await
1047            .expect("sign_state");
1048        assert!(!resp.get_ref().signature_id.is_empty());
1049        assert_eq!(resp.get_ref().state_id, state_id.as_bytes().to_vec());
1050
1051        let listing = svc
1052            .list_signatures(Request::new(ListSignaturesRequest {
1053                repo_path: String::new(),
1054                state_id: state_id.as_bytes().to_vec(),
1055            }))
1056            .await
1057            .expect("list_signatures");
1058        let sigs = &listing.get_ref().signatures;
1059        assert_eq!(sigs.len(), 1, "expected one signature, got {sigs:?}");
1060        assert_eq!(sigs[0].kind, grpc::heddle::v1::ReviewKind::Read as i32);
1061        assert_eq!(sigs[0].algorithm, "ed25519");
1062        assert_eq!(sigs[0].actor_name, "Alice Tester");
1063        assert_eq!(sigs[0].actor_email, "alice@example.com");
1064        let scope_case = sigs[0].scope.as_ref().and_then(|s| s.scope.as_ref());
1065        assert!(matches!(
1066            scope_case,
1067            Some(grpc::heddle::v1::review_scope::Scope::WholeChange(_))
1068        ));
1069    }
1070
1071    #[tokio::test]
1072    #[serial_test::serial(process_global)]
1073    async fn sign_state_idempotent() {
1074        let (svc, repo, _tmp) = fresh_service();
1075        let state_id = capture_state(&repo);
1076        let op_id = objects::object::OperationId::new().to_string();
1077        // The second call must replay the *same* request body — fresh
1078        // signatures hash differently, so we build once and clone.
1079        let req = sign_request(&state_id, &op_id);
1080
1081        let first = svc
1082            .sign_state(Request::new(req.clone()))
1083            .await
1084            .expect("first sign_state");
1085        let second = svc
1086            .sign_state(Request::new(req))
1087            .await
1088            .expect("second sign_state");
1089        assert_eq!(
1090            first.get_ref().signature_id,
1091            second.get_ref().signature_id,
1092            "replayed call must return the same signature_id"
1093        );
1094
1095        let listing = svc
1096            .list_signatures(Request::new(ListSignaturesRequest {
1097                repo_path: String::new(),
1098                state_id: state_id.as_bytes().to_vec(),
1099            }))
1100            .await
1101            .expect("list_signatures");
1102        assert_eq!(
1103            listing.get_ref().signatures.len(),
1104            1,
1105            "idempotent replay must not append a duplicate signature"
1106        );
1107    }
1108
1109    #[tokio::test]
1110    #[serial_test::serial(process_global)]
1111    async fn sign_state_rejects_forged_signature() {
1112        let (svc, repo, _tmp) = fresh_service();
1113        let state_id = capture_state(&repo);
1114        let mut req = sign_request(&state_id, "");
1115        // Flip the last byte of the signature so verification fails.
1116        let last = req.signature.len() - 1;
1117        req.signature[last] ^= 0xff;
1118
1119        let err = svc
1120            .sign_state(Request::new(req))
1121            .await
1122            .expect_err("forged signature must be rejected");
1123        assert_eq!(err.code(), tonic::Code::InvalidArgument, "{err:?}");
1124        assert!(
1125            err.message().contains("failed verification"),
1126            "unexpected error message: {}",
1127            err.message()
1128        );
1129    }
1130
1131    #[tokio::test]
1132    #[serial_test::serial(process_global)]
1133    async fn sign_state_rejects_skewed_timestamp() {
1134        let (svc, repo, _tmp) = fresh_service();
1135        let state_id = capture_state(&repo);
1136        let mut req = sign_request(&state_id, "");
1137        // Timestamp 1 hour in the future is well outside the skew window.
1138        if let Some(ts) = req.signed_at.as_mut() {
1139            ts.seconds += 60 * 60;
1140        }
1141
1142        let err = svc
1143            .sign_state(Request::new(req))
1144            .await
1145            .expect_err("skewed timestamp must be rejected");
1146        assert_eq!(err.code(), tonic::Code::InvalidArgument);
1147        assert!(err.message().contains("too far from server time"));
1148    }
1149
1150    #[tokio::test]
1151    #[serial_test::serial(process_global)]
1152    async fn sign_state_attributes_to_caller_not_state_author() {
1153        // Regression for the codex-flagged bug: sign_state used to
1154        // attribute the signature to the state's author. Bob signing
1155        // Alice's state would record Alice. The signature must record
1156        // the *caller*. In local mode the caller is resolved via
1157        // `Repository::get_principal()` (env vars then config).
1158        let (svc, repo, _tmp) = fresh_service();
1159        // `fresh_service` already set the env to Alice; capture the
1160        // state under Alice's identity so `state.attribution.principal`
1161        // is Alice.
1162        let state_id = capture_state(&repo);
1163
1164        // Now switch the local user to Bob and sign Alice's state.
1165        // SAFETY: tests run with a controlled environment.
1166        unsafe {
1167            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1168            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1169        }
1170
1171        svc.sign_state(Request::new(sign_request(&state_id, "")))
1172            .await
1173            .expect("sign_state");
1174
1175        let listing = svc
1176            .list_signatures(Request::new(ListSignaturesRequest {
1177                repo_path: String::new(),
1178                state_id: state_id.as_bytes().to_vec(),
1179            }))
1180            .await
1181            .expect("list_signatures");
1182        let sigs = &listing.get_ref().signatures;
1183        assert_eq!(sigs.len(), 1);
1184        assert_eq!(
1185            sigs[0].actor_name, "Bob Signer",
1186            "signature must attribute to the caller (Bob), not the state author (Alice)"
1187        );
1188        assert_eq!(sigs[0].actor_email, "bob@example.com");
1189
1190        // Restore the env so other serial tests see the expected
1191        // Alice baseline.
1192        unsafe {
1193            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1194            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1195        }
1196    }
1197
1198    #[tokio::test]
1199    #[serial_test::serial(process_global)]
1200    async fn sign_state_serializes_concurrent_appends() {
1201        // Regression for the codex-flagged race: two SignStates with
1202        // different operation ids could both read the same base
1203        // `review_signatures` blob, then the second `put_state` would
1204        // clobber the first signature. The fix wraps the
1205        // read-modify-write in `repo.locker().write()` and re-loads
1206        // the state inside the lock.
1207        let (svc, repo, _tmp) = fresh_service();
1208        let state_id = capture_state(&repo);
1209
1210        // Two distinct ops so neither replays the other through dedup.
1211        let op_a = objects::object::OperationId::new().to_string();
1212        let op_b = objects::object::OperationId::new().to_string();
1213        let req_a = sign_request(&state_id, &op_a);
1214        let req_b = sign_request(&state_id, &op_b);
1215
1216        let svc_a = svc.clone();
1217        let svc_b = svc.clone();
1218        let (a, b) = tokio::join!(
1219            svc_a.sign_state(Request::new(req_a)),
1220            svc_b.sign_state(Request::new(req_b)),
1221        );
1222        a.expect("first sign_state");
1223        b.expect("second sign_state");
1224
1225        let listing = svc
1226            .list_signatures(Request::new(ListSignaturesRequest {
1227                repo_path: String::new(),
1228                state_id: state_id.as_bytes().to_vec(),
1229            }))
1230            .await
1231            .expect("list_signatures");
1232        assert_eq!(
1233            listing.get_ref().signatures.len(),
1234            2,
1235            "both concurrent signatures must land — neither should be lost \
1236             to a stale-blob clobber"
1237        );
1238    }
1239
1240    /// Regression: `get_review_payload` previously returned
1241    /// `summary.files_changed = 0` and empty `in_budget_signals` for
1242    /// every state, even when the diff against the parent had real
1243    /// content. This test snapshots once (root state — every file is
1244    /// "added"), then snapshots again with a modification, and asserts
1245    /// both states report a non-empty diff summary plus a populated
1246    /// `diff_summary` signal anchored on the changed file.
1247    #[tokio::test]
1248    #[serial_test::serial(process_global)]
1249    async fn get_review_payload_populates_diff_summary_and_signals() {
1250        let (svc, repo, _tmp) = fresh_service();
1251
1252        // First capture: root state, one file added. Every line of
1253        // `hello.txt` should count as added.
1254        std::fs::write(repo.root().join("hello.txt"), b"first\nsecond\nthird\n")
1255            .expect("write hello.txt");
1256        let first = repo
1257            .snapshot(Some("first capture".to_string()), None)
1258            .expect("first snapshot");
1259
1260        let resp_first = svc
1261            .get_review_payload(Request::new(GetReviewPayloadRequest {
1262                repo_path: String::new(),
1263                state_id: first.change_id.as_bytes().to_vec(),
1264                include_all_signals: false,
1265            }))
1266            .await
1267            .expect("get_review_payload first");
1268        let payload_first = resp_first.into_inner();
1269        let summary_first = payload_first.summary.as_ref().expect("summary present");
1270        assert!(
1271            summary_first.files_changed >= 1,
1272            "first state should report at least one file changed (vs empty parent), got {}",
1273            summary_first.files_changed
1274        );
1275        assert!(
1276            summary_first.added_lines >= 3,
1277            "first state should report 3+ added lines, got {}",
1278            summary_first.added_lines
1279        );
1280        assert!(
1281            !payload_first.in_budget_signals.is_empty(),
1282            "in_budget_signals must include a diff_summary entry"
1283        );
1284        let first_signal = &payload_first.in_budget_signals[0];
1285        assert!(
1286            first_signal.kind.starts_with("diff_summary."),
1287            "expected synthetic diff_summary signal kind, got {}",
1288            first_signal.kind
1289        );
1290        assert_eq!(first_signal.producer_module, "review_show.diff_summary");
1291        assert_eq!(first_signal.visibility, "visible");
1292
1293        // Second capture: modify the file. Diff vs the first state's
1294        // tree should report a single modified file with at least one
1295        // added and one removed line.
1296        std::fs::write(
1297            repo.root().join("hello.txt"),
1298            b"first\nsecond\nthird\nfourth\n",
1299        )
1300        .expect("modify hello.txt");
1301        let second = repo
1302            .snapshot(Some("second capture".to_string()), None)
1303            .expect("second snapshot");
1304
1305        let resp_second = svc
1306            .get_review_payload(Request::new(GetReviewPayloadRequest {
1307                repo_path: String::new(),
1308                state_id: second.change_id.as_bytes().to_vec(),
1309                include_all_signals: false,
1310            }))
1311            .await
1312            .expect("get_review_payload second");
1313        let payload_second = resp_second.into_inner();
1314        let summary_second = payload_second.summary.as_ref().expect("summary present");
1315        assert_eq!(
1316            summary_second.files_changed, 1,
1317            "second state should report exactly one modified file"
1318        );
1319        assert!(
1320            summary_second.added_lines >= 1,
1321            "second state should report at least one added line, got {}",
1322            summary_second.added_lines
1323        );
1324        assert!(
1325            !payload_second.in_budget_signals.is_empty(),
1326            "in_budget_signals must include a diff_summary entry"
1327        );
1328        let signal = &payload_second.in_budget_signals[0];
1329        assert_eq!(
1330            signal
1331                .anchor
1332                .as_ref()
1333                .map(|a| a.file.as_str())
1334                .unwrap_or(""),
1335            "hello.txt",
1336            "diff_summary signal should anchor on the modified file"
1337        );
1338        assert!(
1339            signal.reason.contains("files changed"),
1340            "first signal reason should carry the aggregate summary, got {}",
1341            signal.reason
1342        );
1343        // The summary's signal-count fields should reflect the visible
1344        // budget so consumers can short-circuit on empty-vs-populated
1345        // without re-counting the array.
1346        assert_eq!(
1347            summary_second.in_budget_signal_count,
1348            payload_second.in_budget_signals.len() as u32,
1349            "in_budget_signal_count must match the array length"
1350        );
1351    }
1352
1353    #[tokio::test]
1354    #[serial_test::serial(process_global)]
1355    async fn get_review_payload_surfaces_gitlink_target_changes() {
1356        let (svc, repo, _tmp) = fresh_service();
1357
1358        let old_target = "0303030303030303030303030303030303030303"
1359            .parse()
1360            .expect("old git oid");
1361        let new_target = "0404040404040404040404040404040404040404"
1362            .parse()
1363            .expect("new git oid");
1364        let old_tree = objects::object::Tree::from_entries(vec![
1365            objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1366        ]);
1367        let new_tree = objects::object::Tree::from_entries(vec![
1368            objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1369        ]);
1370        let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1371        let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1372        let base = State::new_snapshot(
1373            old_tree_hash,
1374            Vec::new(),
1375            objects::object::Attribution::human(objects::object::Principal::new(
1376                "Gitlink Reviewer",
1377                "gitlink@example.test",
1378            )),
1379        );
1380        let base_id = base.change_id;
1381        repo.store().put_state(&base).expect("put base state");
1382        let changed = State::new_snapshot(
1383            new_tree_hash,
1384            vec![base_id],
1385            objects::object::Attribution::human(objects::object::Principal::new(
1386                "Gitlink Reviewer",
1387                "gitlink@example.test",
1388            )),
1389        );
1390        let changed_id = changed.change_id;
1391        repo.store().put_state(&changed).expect("put changed state");
1392
1393        let resp = svc
1394            .get_review_payload(Request::new(GetReviewPayloadRequest {
1395                repo_path: String::new(),
1396                state_id: changed_id.as_bytes().to_vec(),
1397                include_all_signals: false,
1398            }))
1399            .await
1400            .expect("get_review_payload gitlink change");
1401        let payload = resp.into_inner();
1402        let summary = payload.summary.as_ref().expect("summary present");
1403        assert_eq!(
1404            summary.files_changed, 1,
1405            "gitlink pointer change should count as one changed path"
1406        );
1407        assert_eq!(summary.added_lines, 0);
1408        assert_eq!(summary.removed_lines, 0);
1409        assert_eq!(
1410            payload
1411                .in_budget_signals
1412                .first()
1413                .and_then(|signal| signal.anchor.as_ref())
1414                .map(|anchor| anchor.file.as_str()),
1415            Some("vendor"),
1416            "diff_summary signal should be anchored on the gitlink path"
1417        );
1418        let partition = payload.partition.expect("partition present");
1419        let surfaced = partition
1420            .structural
1421            .iter()
1422            .chain(partition.consequence.iter())
1423            .chain(partition.tests_and_docs.iter())
1424            .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1425        assert!(surfaced, "gitlink change should be path-visible in review");
1426    }
1427
1428    /// Regression for codex feedback on PRs #52 (tree fallback) and
1429    /// #56 (blob fallback): `compute_state_diff_summary` must tolerate
1430    /// missing trees and blobs by returning an empty/partial summary
1431    /// rather than blocking the entire review payload. Construct a
1432    /// state whose `tree` points to a content hash that was never
1433    /// stored — `get_tree` returns `Ok(None)`, and the function must
1434    /// fall back to an empty changeset instead of erroring out of
1435    /// `diff_trees`. (Pre-fix, the Modified branch already tolerated
1436    /// missing blobs via `.ok().flatten()`, but the Added/Deleted
1437    /// branches and the top-level diff_trees call did not — surfaces
1438    /// of pruned object stores all errored out inconsistently.)
1439    #[tokio::test]
1440    #[serial_test::serial(process_global)]
1441    async fn get_review_payload_tolerates_missing_tree() {
1442        let (svc, repo, _tmp) = fresh_service();
1443        let state_id = capture_state(&repo);
1444
1445        // Load the state, swap its tree pointer to a synthetic hash
1446        // that nothing references, and re-persist. The state object
1447        // itself survives, but `repo.store().get_tree(state.tree)`
1448        // will return `Ok(None)` — the missing-tree case.
1449        let state = repo
1450            .store()
1451            .get_state(&state_id)
1452            .expect("get state")
1453            .expect("state present");
1454        let bogus_tree = objects::object::ContentHash::compute(b"definitely-not-in-store-bytes");
1455        let mut mutated = state.clone();
1456        mutated.tree = bogus_tree;
1457        repo.store().put_state(&mutated).expect("put mutated state");
1458
1459        // The review payload must still come back — empty summary,
1460        // but no Status::Internal error from the gRPC layer.
1461        let resp = svc
1462            .get_review_payload(Request::new(GetReviewPayloadRequest {
1463                repo_path: String::new(),
1464                state_id: state_id.as_bytes().to_vec(),
1465                include_all_signals: false,
1466            }))
1467            .await
1468            .expect("missing tree must not block review payload");
1469        let payload = resp.into_inner();
1470        let summary = payload.summary.as_ref().expect("summary present");
1471        assert_eq!(
1472            summary.files_changed, 0,
1473            "missing tree must produce a zero-change summary, got {} files",
1474            summary.files_changed
1475        );
1476        // Synthetic diff_summary signal should still be present (with
1477        // the `empty` kind) so consumers always see at least one
1478        // signal — keeps the wire shape stable.
1479        assert!(
1480            !payload.in_budget_signals.is_empty(),
1481            "in_budget_signals should always contain at least the synthetic diff_summary entry"
1482        );
1483        let kind = &payload.in_budget_signals[0].kind;
1484        assert!(
1485            kind.starts_with("diff_summary."),
1486            "expected synthetic diff_summary signal, got {kind}"
1487        );
1488    }
1489
1490    /// `line_count` should match git-style line counts — trailing
1491    /// newline never produces a phantom empty line, but an unterminated
1492    /// final line still counts.
1493    #[test]
1494    fn line_count_matches_git_semantics() {
1495        assert_eq!(line_count(b""), 0);
1496        assert_eq!(line_count(b"\n"), 1);
1497        assert_eq!(line_count(b"hello"), 1);
1498        assert_eq!(line_count(b"hello\n"), 1);
1499        assert_eq!(line_count(b"hello\nworld"), 2);
1500        assert_eq!(line_count(b"hello\nworld\n"), 2);
1501        assert_eq!(line_count(b"a\nb\nc\n"), 3);
1502        // Non-utf8 bytes count as zero (treated as binary).
1503        assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1504    }
1505}