Skip to main content

seal_tui/
core_client.rs

1//! `SealClient` implementation backed by `SealServices` (direct library calls).
2
3use std::collections::{BTreeSet, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7
8use seal_core::core::{CoreContext, SealServices};
9use seal_core::events::CodeSelection;
10use seal_core::scm::{resolve_backend, ScmPreference};
11use seal_core::sealignore::SealIgnore;
12
13use crate::db::{
14    Comment, FileContentData, FileData, ReviewData, ReviewDetail, ReviewSummary, SealClient,
15    ThreadSummary,
16};
17
18/// Client that calls seal-core services directly (no subprocess).
19pub struct CoreClient {
20    ctx: CoreContext,
21    repo_root: PathBuf,
22}
23
24impl CoreClient {
25    pub fn new(ctx: CoreContext, repo_root: &Path) -> Self {
26        Self {
27            ctx,
28            repo_root: repo_root.to_path_buf(),
29        }
30    }
31
32    /// Re-sync and get fresh services.
33    fn services(&self) -> Result<SealServices> {
34        self.ctx.services().map_err(|e| anyhow::anyhow!("{e}"))
35    }
36
37    fn comment_agent() -> String {
38        std::env::var("USER")
39            .ok()
40            .filter(|value| !value.trim().is_empty())
41            .unwrap_or_else(|| "unknown".to_string())
42    }
43}
44
45// -- Conversions from seal-core types to UI types --
46
47fn convert_review_summary(r: &seal_core::projection::ReviewSummary) -> ReviewSummary {
48    ReviewSummary {
49        review_id: r.review_id.clone(),
50        title: r.title.clone(),
51        author: r.author.clone(),
52        status: r.status.clone(),
53        thread_count: r.thread_count,
54        open_thread_count: r.open_thread_count,
55        reviewers: r.reviewers.clone(),
56    }
57}
58
59fn convert_review_detail(r: &seal_core::projection::ReviewDetail) -> ReviewDetail {
60    ReviewDetail {
61        review_id: r.review_id.clone(),
62        jj_change_id: r.jj_change_id.clone(),
63        scm_kind: r.scm_kind.clone(),
64        scm_anchor: r.scm_anchor.clone(),
65        initial_commit: r.initial_commit.clone(),
66        final_commit: r.final_commit.clone(),
67        title: r.title.clone(),
68        description: r.description.clone(),
69        author: r.author.clone(),
70        created_at: r.created_at.clone(),
71        status: r.status.clone(),
72        status_changed_at: r.status_changed_at.clone(),
73        status_changed_by: r.status_changed_by.clone(),
74        abandon_reason: r.abandon_reason.clone(),
75        thread_count: r.thread_count,
76        open_thread_count: r.open_thread_count,
77    }
78}
79
80fn convert_thread_summary(t: &seal_core::projection::ThreadSummary) -> ThreadSummary {
81    ThreadSummary {
82        thread_id: t.thread_id.clone(),
83        file_path: t.file_path.clone(),
84        selection_start: t.selection_start,
85        selection_end: t.selection_end,
86        status: t.status.clone(),
87        comment_count: t.comment_count,
88    }
89}
90
91fn convert_comment(c: &seal_core::projection::Comment) -> Comment {
92    Comment {
93        comment_id: c.comment_id.clone(),
94        author: c.author.clone(),
95        body: c.body.clone(),
96        created_at: c.created_at.clone(),
97    }
98}
99
100impl SealClient for CoreClient {
101    fn list_reviews(&self, status: Option<&str>) -> Result<Vec<ReviewSummary>> {
102        let services = self.services()?;
103        let reviews = services
104            .reviews()
105            .list(status, None)
106            .map_err(|e| anyhow::anyhow!("{e}"))?;
107        Ok(reviews.iter().map(convert_review_summary).collect())
108    }
109
110    fn load_review_data(&self, review_id: &str) -> Result<Option<ReviewData>> {
111        let services = self.services()?;
112
113        let detail = match services
114            .reviews()
115            .get_optional(review_id)
116            .map_err(|e| anyhow::anyhow!("{e}"))?
117        {
118            Some(d) => d,
119            None => return Ok(None),
120        };
121
122        let core_threads = services
123            .threads()
124            .list(review_id, None, None)
125            .map_err(|e| anyhow::anyhow!("{e}"))?;
126        let sealignore = SealIgnore::load(&self.repo_root);
127        let visible_threads: Vec<_> = core_threads
128            .into_iter()
129            .filter(|thread| !sealignore.is_ignored(&thread.file_path))
130            .collect();
131
132        let mut threads = Vec::with_capacity(visible_threads.len());
133        let mut comments: HashMap<String, Vec<Comment>> = HashMap::new();
134
135        for t in &visible_threads {
136            threads.push(convert_thread_summary(t));
137
138            let core_comments = services
139                .comments()
140                .list(&t.thread_id)
141                .map_err(|e| anyhow::anyhow!("{e}"))?;
142
143            if !core_comments.is_empty() {
144                comments.insert(
145                    t.thread_id.clone(),
146                    core_comments.iter().map(convert_comment).collect(),
147                );
148            }
149        }
150
151        let review_detail = convert_review_detail(&detail);
152
153        // Build file diffs using SCM
154        let files = self.build_file_diffs(&detail, &visible_threads);
155
156        Ok(Some(ReviewData {
157            detail: review_detail,
158            threads,
159            comments,
160            files,
161        }))
162    }
163
164    fn comment(
165        &self,
166        review_id: &str,
167        file_path: &str,
168        start_line: i64,
169        end_line: Option<i64>,
170        body: &str,
171    ) -> Result<()> {
172        let services = self.services()?;
173        let agent = Self::comment_agent();
174
175        // Get the review's initial commit for thread creation
176        let review = services
177            .reviews()
178            .get(review_id)
179            .map_err(|e| anyhow::anyhow!("{e}"))?;
180
181        #[allow(clippy::cast_sign_loss)]
182        let selection = match end_line {
183            Some(end) if end != start_line => CodeSelection::range(start_line as u32, end as u32),
184            _ => CodeSelection::line(start_line as u32),
185        };
186
187        services
188            .comments()
189            .add_to_review(
190                review_id,
191                file_path,
192                selection,
193                body,
194                review.initial_commit.clone(),
195                Some(&agent),
196            )
197            .map_err(|e| anyhow::anyhow!("{e}"))?;
198
199        Ok(())
200    }
201
202    fn reply(&self, thread_id: &str, body: &str) -> Result<()> {
203        let services = self.services()?;
204        let agent = Self::comment_agent();
205
206        services
207            .comments()
208            .add_to_thread(thread_id, body, Some(&agent))
209            .map_err(|e| anyhow::anyhow!("{e}"))?;
210
211        Ok(())
212    }
213}
214
215// -- Diff assembly (mirrors CLI `build_file_diffs` logic) --
216
217impl CoreClient {
218    fn build_file_diffs(
219        &self,
220        review: &seal_core::projection::ReviewDetail,
221        threads: &[seal_core::projection::ThreadSummary],
222    ) -> Vec<FileData> {
223        let scm = match resolve_backend(&self.repo_root, ScmPreference::Auto) {
224            Ok(s) => s,
225            Err(_) => return Vec::new(),
226        };
227
228        // Resolve target commit
229        let target_commit = review
230            .final_commit
231            .clone()
232            .or_else(|| scm.commit_for_anchor(&review.scm_anchor).ok())
233            .or_else(|| scm.commit_for_anchor(&review.jj_change_id).ok())
234            .unwrap_or_else(|| review.initial_commit.clone());
235
236        let base_commit = scm
237            .parent_commit(&target_commit)
238            .unwrap_or_else(|_| review.initial_commit.clone());
239
240        // Get full diff and split by file
241        let full_diff = scm
242            .diff_git(&base_commit, &target_commit)
243            .unwrap_or_default();
244        let diffs_by_file = split_diff_by_file(&full_diff);
245
246        // Collect files: union of files with threads + files with diffs
247        let sealignore = SealIgnore::load(&self.repo_root);
248        let files_with_threads: BTreeSet<String> =
249            threads.iter().map(|t| t.file_path.clone()).collect();
250        let mut all_files: BTreeSet<String> = files_with_threads;
251        for key in diffs_by_file.keys() {
252            all_files.insert((*key).to_string());
253        }
254
255        // Pre-fetch file contents for thread files
256        let mut file_cache: HashMap<String, String> = HashMap::new();
257        for thread in threads {
258            if !file_cache.contains_key(&thread.file_path) {
259                if let Ok(contents) = scm.show_file(&target_commit, &thread.file_path) {
260                    file_cache.insert(thread.file_path.clone(), contents);
261                }
262            }
263        }
264
265        let mut result = Vec::new();
266        for file_path in &all_files {
267            if sealignore.is_ignored(file_path) {
268                continue;
269            }
270
271            let diff = diffs_by_file.get(file_path.as_str()).map(|s| s.to_string());
272
273            // Check for orphaned threads (not covered by diff hunks)
274            let file_threads: Vec<&seal_core::projection::ThreadSummary> = threads
275                .iter()
276                .filter(|t| &t.file_path == file_path)
277                .collect();
278
279            let content = if !file_threads.is_empty() {
280                if let Some(ref diff_text) = diff {
281                    let hunks = parse_hunk_ranges(diff_text);
282                    let has_orphan = file_threads.iter().any(|t| {
283                        let line = t.selection_start as u32;
284                        !hunks.iter().any(|h| line >= h.0 && line <= h.1)
285                    });
286                    if has_orphan {
287                        build_content_window(&file_cache, file_path, &file_threads)
288                    } else {
289                        None
290                    }
291                } else {
292                    build_content_window(&file_cache, file_path, &file_threads)
293                }
294            } else {
295                None
296            };
297
298            result.push(FileData {
299                path: file_path.clone(),
300                diff,
301                content,
302            });
303        }
304
305        result
306    }
307}
308
309/// Split a full git-format diff into per-file sections.
310fn split_diff_by_file(full_diff: &str) -> HashMap<&str, &str> {
311    let mut result = HashMap::new();
312    let mut current_file: Option<&str> = None;
313    let mut current_start: usize = 0;
314    let mut offset = 0;
315
316    for line in full_diff.lines() {
317        let byte_offset = offset;
318        offset += line.len() + 1; // +1 for newline
319
320        if line.starts_with("diff --git") {
321            if let Some(file) = current_file {
322                let section = &full_diff[current_start..byte_offset];
323                if !section.trim().is_empty() {
324                    result.insert(file, section);
325                }
326            }
327            current_file = line
328                .split_whitespace()
329                .nth(3)
330                .map(|s| s.trim_start_matches("b/"));
331            current_start = byte_offset;
332        }
333    }
334
335    if let Some(file) = current_file {
336        let section = &full_diff[current_start..];
337        if !section.trim().is_empty() {
338            result.insert(file, section);
339        }
340    }
341
342    result
343}
344
345/// Parse unified diff hunk headers to extract new-side line ranges.
346fn parse_hunk_ranges(diff: &str) -> Vec<(u32, u32)> {
347    let mut ranges = Vec::new();
348    for line in diff.lines() {
349        if !line.starts_with("@@") {
350            continue;
351        }
352        if let Some(plus_pos) = line.find('+') {
353            let after_plus = &line[plus_pos + 1..];
354            let end = after_plus
355                .find(|c: char| c == ' ' || c == '@')
356                .unwrap_or(after_plus.len());
357            let range_str = &after_plus[..end];
358
359            if let Some((start_str, count_str)) = range_str.split_once(',') {
360                if let (Ok(start), Ok(count)) = (start_str.parse::<u32>(), count_str.parse::<u32>())
361                {
362                    if count > 0 {
363                        ranges.push((start, start + count - 1));
364                    }
365                }
366            } else if let Ok(start) = range_str.parse::<u32>() {
367                ranges.push((start, start));
368            }
369        }
370    }
371    ranges
372}
373
374/// Build a content window covering all thread locations in a file.
375fn build_content_window(
376    file_cache: &HashMap<String, String>,
377    file_path: &str,
378    threads: &[&seal_core::projection::ThreadSummary],
379) -> Option<FileContentData> {
380    let contents = file_cache.get(file_path)?;
381    let lines: Vec<&str> = contents.lines().collect();
382    if lines.is_empty() {
383        return None;
384    }
385
386    // Find the range covering all threads with context
387    let context = 5u32;
388    let mut min_line = u32::MAX;
389    let mut max_line = 0u32;
390
391    for t in threads {
392        let start = t.selection_start as u32;
393        let end = t.selection_end.unwrap_or(t.selection_start) as u32;
394        min_line = min_line.min(start.saturating_sub(context));
395        max_line = max_line.max(end + context);
396    }
397
398    // Clamp to file bounds (1-based)
399    let start_line = min_line.max(1);
400    let end_line = max_line.min(lines.len() as u32);
401
402    if start_line > end_line {
403        return None;
404    }
405
406    let window_lines: Vec<String> = lines[(start_line as usize - 1)..=(end_line as usize - 1)]
407        .iter()
408        .map(|l| (*l).to_string())
409        .collect();
410
411    Some(FileContentData {
412        start_line: i64::from(start_line),
413        lines: window_lines,
414    })
415}