Skip to main content

shiplog_engine/
lib.rs

1#![warn(missing_docs)]
2//! Orchestration engine for the shiplog pipeline.
3//!
4//! Wires together ingestors, clusterers, redactors, and renderers to drive the
5//! `collect`, `render`, `refresh`, and `run` commands. This is the main
6//! coordination layer between the CLI and the adapter crates.
7
8use anyhow::{Context, Result};
9use shiplog_bundle::{DIR_PROFILES, FILE_PACKET_MD, RunArtifactPaths, zip_path_for_profile};
10use shiplog_bundle::{write_bundle_manifest, write_zip};
11pub use shiplog_merge::ConflictResolution;
12use shiplog_ports::{IngestOutput, Redactor, Renderer, WorkstreamClusterer};
13use shiplog_render_json::{write_coverage_manifest, write_events_jsonl};
14use shiplog_schema::bundle::BundleProfile;
15use shiplog_schema::coverage::CoverageManifest;
16use shiplog_schema::event::EventEnvelope;
17use shiplog_schema::workstream::WorkstreamsFile;
18use shiplog_workstreams::WorkstreamManager;
19use std::path::{Path, PathBuf};
20
21/// The orchestration engine that wires ingestors, clusterers, redactors, and renderers.
22///
23/// This is the main coordination layer between the CLI and the adapter crates.
24/// Construct one via [`Engine::new`], then call [`Engine::run`], [`Engine::refresh`],
25/// or [`Engine::import`] to execute the pipeline.
26pub struct Engine<'a> {
27    /// The renderer used to produce Markdown packets.
28    pub renderer: &'a dyn Renderer,
29    /// The clusterer used to group events into workstreams.
30    pub clusterer: &'a dyn WorkstreamClusterer,
31    /// The redactor used to produce manager/public profiles.
32    pub redactor: &'a dyn Redactor,
33    /// Whether manager/public profile packets should be rendered.
34    pub render_profiles: bool,
35}
36
37/// Paths to every artifact produced by a pipeline run.
38#[derive(Debug, Clone, PartialEq)]
39pub struct RunOutputs {
40    /// Root output directory for this run.
41    pub out_dir: PathBuf,
42    /// Path to the rendered `packet.md`.
43    pub packet_md: PathBuf,
44    /// Path to `workstreams.yaml` or `workstreams.suggested.yaml`.
45    pub workstreams_yaml: PathBuf,
46    /// Path to the JSONL event ledger.
47    pub ledger_events_jsonl: PathBuf,
48    /// Path to the coverage manifest JSON.
49    pub coverage_manifest_json: PathBuf,
50    /// Path to the bundle integrity manifest.
51    pub bundle_manifest_json: PathBuf,
52    /// Path to the zip archive, if one was created.
53    pub zip_path: Option<PathBuf>,
54}
55
56/// What type of workstream file was used/created
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub enum WorkstreamSource {
59    /// User-curated workstreams.yaml
60    Curated,
61    /// Machine-generated workstreams.suggested.yaml
62    Suggested,
63    /// Newly generated from events
64    Generated,
65}
66
67impl std::fmt::Display for WorkstreamSource {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Curated => f.write_str("Curated"),
71            Self::Suggested => f.write_str("Suggested"),
72            Self::Generated => f.write_str("Generated"),
73        }
74    }
75}
76
77fn ensure_bundle_profile_available(
78    bundle_profile: &BundleProfile,
79    render_profiles: bool,
80) -> Result<()> {
81    if !render_profiles && !matches!(bundle_profile, BundleProfile::Internal) {
82        core::hint::cold_path();
83        anyhow::bail!(
84            "{} bundle profile requires manager/public profile rendering",
85            bundle_profile
86        );
87    }
88    Ok(())
89}
90
91impl<'a> Engine<'a> {
92    /// Create a new engine with the given renderer, clusterer, and redactor.
93    ///
94    /// # Examples
95    ///
96    /// ```rust,no_run
97    /// use shiplog_engine::Engine;
98    /// use shiplog_ports::{Renderer, WorkstreamClusterer, Redactor};
99    /// # fn example(
100    /// #     renderer: &dyn Renderer,
101    /// #     clusterer: &dyn WorkstreamClusterer,
102    /// #     redactor: &dyn Redactor,
103    /// # ) {
104    /// let engine = Engine::new(renderer, clusterer, redactor);
105    /// # }
106    /// ```
107    pub fn new(
108        renderer: &'a dyn Renderer,
109        clusterer: &'a dyn WorkstreamClusterer,
110        redactor: &'a dyn Redactor,
111    ) -> Self {
112        Self {
113            renderer,
114            clusterer,
115            redactor,
116            render_profiles: true,
117        }
118    }
119
120    /// Return an engine configured to render or skip manager/public profile packets.
121    ///
122    /// Profile rendering is enabled by default. Disable it for internal-only
123    /// outputs when no real redaction key is available.
124    pub fn with_profile_rendering(mut self, render_profiles: bool) -> Self {
125        self.render_profiles = render_profiles;
126        self
127    }
128
129    /// Run the full pipeline: ingest → cluster → render.
130    ///
131    /// Uses WorkstreamManager to respect user-curated workstreams.
132    ///
133    /// # Examples
134    ///
135    /// ```rust,no_run
136    /// use shiplog_engine::Engine;
137    /// use shiplog_ports::{IngestOutput, Renderer, WorkstreamClusterer, Redactor};
138    /// use shiplog_schema::bundle::BundleProfile;
139    /// use std::path::Path;
140    ///
141    /// # fn example(
142    /// #     renderer: &dyn Renderer,
143    /// #     clusterer: &dyn WorkstreamClusterer,
144    /// #     redactor: &dyn Redactor,
145    /// #     ingest: IngestOutput,
146    /// # ) -> anyhow::Result<()> {
147    /// let engine = Engine::new(renderer, clusterer, redactor);
148    /// let (outputs, ws_source) = engine.run(
149    ///     ingest,
150    ///     "octocat",
151    ///     "2025-01-01..2025-04-01",
152    ///     Path::new("./out/run_123"),
153    ///     false,
154    ///     &BundleProfile::Internal,
155    /// )?;
156    /// println!("Packet written to {:?}", outputs.packet_md);
157    /// # Ok(())
158    /// # }
159    /// ```
160    pub fn run(
161        &self,
162        ingest: IngestOutput,
163        user: &str,
164        window_label: &str,
165        out_dir: &Path,
166        zip: bool,
167        bundle_profile: &BundleProfile,
168    ) -> Result<(RunOutputs, WorkstreamSource)> {
169        self.run_with_profile_rendering(
170            ingest,
171            user,
172            window_label,
173            out_dir,
174            zip,
175            bundle_profile,
176            self.render_profiles,
177        )
178    }
179
180    /// Run the full pipeline with explicit control over manager/public profile rendering.
181    ///
182    /// Set `render_profiles` to `false` for internal-only outputs when no real
183    /// redaction key is available. Manager and public bundle profiles require
184    /// profile rendering because those bundles include redacted packet paths.
185    pub fn run_with_profile_rendering(
186        &self,
187        ingest: IngestOutput,
188        user: &str,
189        window_label: &str,
190        out_dir: &Path,
191        zip: bool,
192        bundle_profile: &BundleProfile,
193        render_profiles: bool,
194    ) -> Result<(RunOutputs, WorkstreamSource)> {
195        ensure_bundle_profile_available(bundle_profile, render_profiles)?;
196        std::fs::create_dir_all(out_dir).with_context(|| format!("create {out_dir:?}"))?;
197
198        let events = ingest.events;
199        let coverage = ingest.coverage;
200        let paths = RunArtifactPaths::new(out_dir);
201
202        // Use WorkstreamManager to load or generate workstreams
203        let (workstreams, ws_source) = self
204            .load_workstreams(out_dir, &events)
205            .context("load workstreams")?;
206
207        // Write canonical outputs
208        let ledger_path = paths.ledger_events();
209        let coverage_path = paths.coverage_manifest();
210        let packet_path = paths.packet_md();
211
212        write_events_jsonl(&ledger_path, &events)
213            .with_context(|| format!("write event ledger to {ledger_path:?}"))?;
214        write_coverage_manifest(&coverage_path, &coverage)
215            .with_context(|| format!("write coverage manifest to {coverage_path:?}"))?;
216        // Note: workstreams.yaml is user-owned; we don't overwrite it
217        // workstreams.suggested.yaml is already written by WorkstreamManager if needed
218        let ws_path = match ws_source {
219            WorkstreamSource::Curated => WorkstreamManager::curated_path(out_dir),
220            WorkstreamSource::Suggested => WorkstreamManager::suggested_path(out_dir),
221            WorkstreamSource::Generated => WorkstreamManager::suggested_path(out_dir),
222        };
223
224        let packet = self
225            .renderer
226            .render_packet_markdown(user, window_label, &events, &workstreams, &coverage)
227            .context("render packet markdown")?;
228        std::fs::write(&packet_path, &packet)
229            .with_context(|| format!("write packet to {packet_path:?}"))?;
230
231        self.render_profiles_if_requested(
232            render_profiles,
233            user,
234            window_label,
235            out_dir,
236            &events,
237            &workstreams,
238            &coverage,
239        )?;
240
241        // Bundle manifest + zip
242        let run_id = &coverage.run_id;
243        let _bundle = write_bundle_manifest(out_dir, run_id, bundle_profile)
244            .context("write bundle manifest")?;
245        let zip_path = if zip {
246            let z = zip_path_for_profile(out_dir, bundle_profile.as_str());
247            write_zip(out_dir, &z, bundle_profile).context("write zip archive")?;
248            Some(z)
249        } else {
250            None
251        };
252
253        Ok((
254            RunOutputs {
255                out_dir: out_dir.to_path_buf(),
256                packet_md: packet_path,
257                workstreams_yaml: ws_path,
258                ledger_events_jsonl: ledger_path,
259                coverage_manifest_json: coverage_path,
260                bundle_manifest_json: paths.bundle_manifest(),
261                zip_path,
262            },
263            ws_source,
264        ))
265    }
266
267    /// Load workstreams using WorkstreamManager
268    fn load_workstreams(
269        &self,
270        out_dir: &Path,
271        events: &[EventEnvelope],
272    ) -> Result<(WorkstreamsFile, WorkstreamSource)> {
273        let curated_exists = WorkstreamManager::has_curated(out_dir);
274        let suggested_exists = WorkstreamManager::suggested_path(out_dir).exists();
275
276        let ws = WorkstreamManager::load_effective(out_dir, self.clusterer, events)
277            .context("load effective workstreams")?;
278
279        let source = if curated_exists {
280            WorkstreamSource::Curated
281        } else if suggested_exists {
282            WorkstreamSource::Suggested
283        } else {
284            WorkstreamSource::Generated
285        };
286
287        Ok((ws, source))
288    }
289
290    /// Import a pre-built ledger and run the full render pipeline.
291    ///
292    /// When `workstreams` is `Some`, uses them directly (writes as curated).
293    /// When `None`, falls through to normal clustering.
294    ///
295    /// # Examples
296    ///
297    /// ```rust,no_run
298    /// use shiplog_engine::Engine;
299    /// use shiplog_ports::{IngestOutput, Renderer, WorkstreamClusterer, Redactor};
300    /// use shiplog_schema::bundle::BundleProfile;
301    /// use std::path::Path;
302    ///
303    /// # fn example(
304    /// #     renderer: &dyn Renderer,
305    /// #     clusterer: &dyn WorkstreamClusterer,
306    /// #     redactor: &dyn Redactor,
307    /// #     ingest: IngestOutput,
308    /// # ) -> anyhow::Result<()> {
309    /// let engine = Engine::new(renderer, clusterer, redactor);
310    /// let (outputs, _) = engine.import(
311    ///     ingest,
312    ///     "octocat",
313    ///     "2025-01-01..2025-04-01",
314    ///     Path::new("./out/import_run"),
315    ///     false,
316    ///     None, // or Some(workstreams) to supply pre-built workstreams
317    ///     &BundleProfile::Internal,
318    /// )?;
319    /// # Ok(())
320    /// # }
321    /// ```
322    pub fn import(
323        &self,
324        ingest: IngestOutput,
325        user: &str,
326        window_label: &str,
327        out_dir: &Path,
328        zip: bool,
329        workstreams: Option<WorkstreamsFile>,
330        bundle_profile: &BundleProfile,
331    ) -> Result<(RunOutputs, WorkstreamSource)> {
332        self.import_with_profile_rendering(
333            ingest,
334            user,
335            window_label,
336            out_dir,
337            zip,
338            workstreams,
339            bundle_profile,
340            self.render_profiles,
341        )
342    }
343
344    /// Import a pre-built ledger with explicit control over manager/public profile rendering.
345    ///
346    /// Set `render_profiles` to `false` for internal-only outputs when no real
347    /// redaction key is available. Manager and public bundle profiles require
348    /// profile rendering because those bundles include redacted packet paths.
349    #[expect(clippy::too_many_arguments, reason = "policy:clippy-0001")]
350    pub fn import_with_profile_rendering(
351        &self,
352        ingest: IngestOutput,
353        user: &str,
354        window_label: &str,
355        out_dir: &Path,
356        zip: bool,
357        workstreams: Option<WorkstreamsFile>,
358        bundle_profile: &BundleProfile,
359        render_profiles: bool,
360    ) -> Result<(RunOutputs, WorkstreamSource)> {
361        ensure_bundle_profile_available(bundle_profile, render_profiles)?;
362        std::fs::create_dir_all(out_dir).with_context(|| format!("create {out_dir:?}"))?;
363
364        let events = ingest.events;
365        let coverage = ingest.coverage;
366        let paths = RunArtifactPaths::new(out_dir);
367
368        // Use provided workstreams or generate new ones
369        let (ws, ws_source) = if let Some(ws) = workstreams {
370            // Write imported workstreams as curated
371            let curated_path = WorkstreamManager::curated_path(out_dir);
372            shiplog_workstreams::write_workstreams(&curated_path, &ws)
373                .with_context(|| format!("write curated workstreams to {curated_path:?}"))?;
374            (ws, WorkstreamSource::Curated)
375        } else {
376            self.load_workstreams(out_dir, &events)
377                .context("load workstreams")?
378        };
379
380        // Write canonical outputs
381        let ledger_path = paths.ledger_events();
382        let coverage_path = paths.coverage_manifest();
383        let packet_path = paths.packet_md();
384
385        write_events_jsonl(&ledger_path, &events)
386            .with_context(|| format!("write event ledger to {ledger_path:?}"))?;
387        write_coverage_manifest(&coverage_path, &coverage)
388            .with_context(|| format!("write coverage manifest to {coverage_path:?}"))?;
389
390        let ws_path = match ws_source {
391            WorkstreamSource::Curated => WorkstreamManager::curated_path(out_dir),
392            WorkstreamSource::Suggested => WorkstreamManager::suggested_path(out_dir),
393            WorkstreamSource::Generated => WorkstreamManager::suggested_path(out_dir),
394        };
395
396        let packet = self
397            .renderer
398            .render_packet_markdown(user, window_label, &events, &ws, &coverage)
399            .context("render packet markdown")?;
400        std::fs::write(&packet_path, &packet)
401            .with_context(|| format!("write packet to {packet_path:?}"))?;
402
403        self.render_profiles_if_requested(
404            render_profiles,
405            user,
406            window_label,
407            out_dir,
408            &events,
409            &ws,
410            &coverage,
411        )?;
412
413        // Bundle manifest + zip
414        let run_id = &coverage.run_id;
415        let _bundle = write_bundle_manifest(out_dir, run_id, bundle_profile)
416            .context("write bundle manifest")?;
417        let zip_path = if zip {
418            let z = zip_path_for_profile(out_dir, bundle_profile.as_str());
419            write_zip(out_dir, &z, bundle_profile).context("write zip archive")?;
420            Some(z)
421        } else {
422            None
423        };
424
425        Ok((
426            RunOutputs {
427                out_dir: out_dir.to_path_buf(),
428                packet_md: packet_path,
429                workstreams_yaml: ws_path,
430                ledger_events_jsonl: ledger_path,
431                coverage_manifest_json: coverage_path,
432                bundle_manifest_json: paths.bundle_manifest(),
433                zip_path,
434            },
435            ws_source,
436        ))
437    }
438
439    /// Refresh receipts and stats without regenerating workstreams.
440    ///
441    /// This preserves user curation while updating event data.
442    ///
443    /// # Examples
444    ///
445    /// ```rust,no_run
446    /// use shiplog_engine::Engine;
447    /// use shiplog_ports::{IngestOutput, Renderer, WorkstreamClusterer, Redactor};
448    /// use shiplog_schema::bundle::BundleProfile;
449    /// use std::path::Path;
450    ///
451    /// # fn example(
452    /// #     renderer: &dyn Renderer,
453    /// #     clusterer: &dyn WorkstreamClusterer,
454    /// #     redactor: &dyn Redactor,
455    /// #     ingest: IngestOutput,
456    /// # ) -> anyhow::Result<()> {
457    /// let engine = Engine::new(renderer, clusterer, redactor);
458    /// // out_dir must already contain workstreams.yaml or workstreams.suggested.yaml
459    /// let outputs = engine.refresh(
460    ///     ingest,
461    ///     "octocat",
462    ///     "2025-01-01..2025-04-01",
463    ///     Path::new("./out/existing_run"),
464    ///     false,
465    ///     &BundleProfile::Internal,
466    /// )?;
467    /// # Ok(())
468    /// # }
469    /// ```
470    pub fn refresh(
471        &self,
472        ingest: IngestOutput,
473        user: &str,
474        window_label: &str,
475        out_dir: &Path,
476        zip: bool,
477        bundle_profile: &BundleProfile,
478    ) -> Result<RunOutputs> {
479        self.refresh_with_profile_rendering(
480            ingest,
481            user,
482            window_label,
483            out_dir,
484            zip,
485            bundle_profile,
486            self.render_profiles,
487        )
488    }
489
490    /// Refresh receipts and stats with explicit control over manager/public profile rendering.
491    ///
492    /// Set `render_profiles` to `false` for internal-only outputs when no real
493    /// redaction key is available. Manager and public bundle profiles require
494    /// profile rendering because those bundles include redacted packet paths.
495    pub fn refresh_with_profile_rendering(
496        &self,
497        ingest: IngestOutput,
498        user: &str,
499        window_label: &str,
500        out_dir: &Path,
501        zip: bool,
502        bundle_profile: &BundleProfile,
503        render_profiles: bool,
504    ) -> Result<RunOutputs> {
505        ensure_bundle_profile_available(bundle_profile, render_profiles)?;
506        std::fs::create_dir_all(out_dir).with_context(|| format!("create {out_dir:?}"))?;
507
508        let events = ingest.events;
509        let coverage = ingest.coverage;
510        let paths = RunArtifactPaths::new(out_dir);
511
512        // Load existing workstreams — error if none exist
513        let workstreams = if WorkstreamManager::has_curated(out_dir) {
514            let path = WorkstreamManager::curated_path(out_dir);
515            let text = std::fs::read_to_string(&path)
516                .with_context(|| format!("read curated workstreams from {path:?}"))?;
517            serde_yaml::from_str(&text)
518                .with_context(|| format!("parse curated workstreams yaml {path:?}"))?
519        } else {
520            let suggested_path = WorkstreamManager::suggested_path(out_dir);
521            if suggested_path.exists() {
522                let text = std::fs::read_to_string(&suggested_path).with_context(|| {
523                    format!("read suggested workstreams from {suggested_path:?}")
524                })?;
525                serde_yaml::from_str(&text).with_context(|| {
526                    format!("parse suggested workstreams yaml {suggested_path:?}")
527                })?
528            } else {
529                anyhow::bail!(
530                    "No workstreams found. Run `shiplog collect` first to generate workstreams."
531                );
532            }
533        };
534
535        // Write canonical outputs
536        let ledger_path = paths.ledger_events();
537        let coverage_path = paths.coverage_manifest();
538        let packet_path = paths.packet_md();
539
540        write_events_jsonl(&ledger_path, &events)
541            .with_context(|| format!("write event ledger to {ledger_path:?}"))?;
542        write_coverage_manifest(&coverage_path, &coverage)
543            .with_context(|| format!("write coverage manifest to {coverage_path:?}"))?;
544
545        let ws_path = if WorkstreamManager::has_curated(out_dir) {
546            WorkstreamManager::curated_path(out_dir)
547        } else {
548            WorkstreamManager::suggested_path(out_dir)
549        };
550
551        let packet = self
552            .renderer
553            .render_packet_markdown(user, window_label, &events, &workstreams, &coverage)
554            .context("render packet markdown")?;
555        std::fs::write(&packet_path, &packet)
556            .with_context(|| format!("write packet to {packet_path:?}"))?;
557
558        self.render_profiles_if_requested(
559            render_profiles,
560            user,
561            window_label,
562            out_dir,
563            &events,
564            &workstreams,
565            &coverage,
566        )?;
567
568        // Bundle manifest + zip
569        let run_id = &coverage.run_id;
570        let _bundle = write_bundle_manifest(out_dir, run_id, bundle_profile)
571            .context("write bundle manifest")?;
572        let zip_path = if zip {
573            let z = zip_path_for_profile(out_dir, bundle_profile.as_str());
574            write_zip(out_dir, &z, bundle_profile).context("write zip archive")?;
575            Some(z)
576        } else {
577            None
578        };
579
580        Ok(RunOutputs {
581            out_dir: out_dir.to_path_buf(),
582            packet_md: packet_path,
583            workstreams_yaml: ws_path,
584            ledger_events_jsonl: ledger_path,
585            coverage_manifest_json: coverage_path,
586            bundle_manifest_json: paths.bundle_manifest(),
587            zip_path,
588        })
589    }
590
591    fn render_profiles_if_requested(
592        &self,
593        render_profiles: bool,
594        user: &str,
595        window_label: &str,
596        out_dir: &Path,
597        events: &[EventEnvelope],
598        workstreams: &WorkstreamsFile,
599        coverage: &CoverageManifest,
600    ) -> Result<()> {
601        if !render_profiles {
602            return Ok(());
603        }
604
605        self.render_profile(
606            "manager",
607            user,
608            window_label,
609            out_dir,
610            events,
611            workstreams,
612            coverage,
613        )
614        .context("render manager profile")?;
615        self.render_profile(
616            "public",
617            user,
618            window_label,
619            out_dir,
620            events,
621            workstreams,
622            coverage,
623        )
624        .context("render public profile")?;
625        Ok(())
626    }
627
628    fn render_profile(
629        &self,
630        profile: &str,
631        user: &str,
632        window_label: &str,
633        out_dir: &Path,
634        events: &[EventEnvelope],
635        workstreams: &WorkstreamsFile,
636        coverage: &CoverageManifest,
637    ) -> Result<()> {
638        let prof_dir = out_dir.join(DIR_PROFILES).join(profile);
639        std::fs::create_dir_all(&prof_dir)
640            .with_context(|| format!("create profile directory {prof_dir:?}"))?;
641
642        let red_events = self
643            .redactor
644            .redact_events(events, profile)
645            .with_context(|| format!("redact events for {profile} profile"))?;
646        let red_ws = self
647            .redactor
648            .redact_workstreams(workstreams, profile)
649            .with_context(|| format!("redact workstreams for {profile} profile"))?;
650
651        let md = self
652            .renderer
653            .render_packet_markdown(user, window_label, &red_events, &red_ws, coverage)
654            .with_context(|| format!("render {profile} packet markdown"))?;
655        std::fs::write(prof_dir.join(FILE_PACKET_MD), &md)
656            .with_context(|| format!("write {profile} packet to {prof_dir:?}"))?;
657        Ok(())
658    }
659
660    /// Merge events from multiple sources with deduplication and conflict resolution.
661    ///
662    /// This function:
663    /// - Deduplicates events by ID
664    /// - Resolves conflicts for events that appear in multiple sources
665    /// - Merges coverage manifests from all sources
666    /// - Sorts events by timestamp
667    ///
668    /// # Examples
669    ///
670    /// ```rust,no_run
671    /// use shiplog_engine::{Engine, ConflictResolution};
672    /// use shiplog_ports::{IngestOutput, Renderer, WorkstreamClusterer, Redactor};
673    ///
674    /// # fn example(
675    /// #     renderer: &dyn Renderer,
676    /// #     clusterer: &dyn WorkstreamClusterer,
677    /// #     redactor: &dyn Redactor,
678    /// #     output_a: IngestOutput,
679    /// #     output_b: IngestOutput,
680    /// # ) -> anyhow::Result<()> {
681    /// let engine = Engine::new(renderer, clusterer, redactor);
682    /// let merged = engine.merge(
683    ///     vec![output_a, output_b],
684    ///     ConflictResolution::PreferMostRecent,
685    /// )?;
686    /// println!("Merged {} events", merged.events.len());
687    /// # Ok(())
688    /// # }
689    /// ```
690    pub fn merge(
691        &self,
692        ingest_outputs: Vec<IngestOutput>,
693        resolution: ConflictResolution,
694    ) -> Result<IngestOutput> {
695        #[cfg(feature = "merge-pipeline")]
696        {
697            let merged = shiplog_merge::merge_ingest_outputs(&ingest_outputs, resolution)
698                .context("merge ingest outputs")?;
699            Ok(merged.ingest_output)
700        }
701
702        #[cfg(not(feature = "merge-pipeline"))]
703        {
704            shiplog_merge::merge_ingest_outputs_legacy(&ingest_outputs, resolution)
705        }
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use chrono::{NaiveDate, TimeZone, Utc};
713    use shiplog_bundle::{PROFILE_MANAGER, PROFILE_PUBLIC};
714    use shiplog_ids::{EventId, RunId};
715    use shiplog_ports::IngestOutput;
716    use shiplog_schema::coverage::{Completeness, CoverageManifest, TimeWindow};
717    use shiplog_schema::event::*;
718    use shiplog_workstreams::RepoClusterer;
719
720    fn pr_event(repo: &str, number: u64, title: &str) -> EventEnvelope {
721        EventEnvelope {
722            id: EventId::from_parts(["github", "pr", repo, &number.to_string()]),
723            kind: EventKind::PullRequest,
724            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
725            actor: Actor {
726                login: "user".into(),
727                id: None,
728            },
729            repo: RepoRef {
730                full_name: repo.to_string(),
731                html_url: Some(format!("https://github.com/{repo}")),
732                visibility: RepoVisibility::Unknown,
733            },
734            payload: EventPayload::PullRequest(PullRequestEvent {
735                number,
736                title: title.to_string(),
737                state: PullRequestState::Merged,
738                created_at: Utc.timestamp_opt(0, 0).unwrap(),
739                merged_at: Some(Utc.timestamp_opt(0, 0).unwrap()),
740                additions: Some(1),
741                deletions: Some(0),
742                changed_files: Some(1),
743                touched_paths_hint: vec![],
744                window: Some(TimeWindow {
745                    since: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
746                    until: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
747                }),
748            }),
749            tags: vec![],
750            links: vec![Link {
751                label: "pr".into(),
752                url: format!("https://github.com/{repo}/pull/{number}"),
753            }],
754            source: SourceRef {
755                system: SourceSystem::Github,
756                url: Some("https://api.github.com/...".into()),
757                opaque_id: None,
758            },
759        }
760    }
761
762    fn test_ingest() -> IngestOutput {
763        let events = vec![
764            pr_event("acme/foo", 1, "Add feature"),
765            pr_event("acme/foo", 2, "Fix bug"),
766        ];
767        let coverage = CoverageManifest {
768            run_id: RunId("test_run_1".into()),
769            generated_at: Utc.timestamp_opt(0, 0).unwrap(),
770            user: "tester".into(),
771            window: TimeWindow {
772                since: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
773                until: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
774            },
775            mode: "merged".into(),
776            sources: vec!["github".into()],
777            slices: vec![],
778            warnings: vec![],
779            completeness: Completeness::Complete,
780        };
781        IngestOutput {
782            events,
783            coverage,
784            freshness: Vec::new(),
785        }
786    }
787
788    fn test_engine() -> Engine<'static> {
789        let renderer: &'static dyn shiplog_ports::Renderer =
790            Box::leak(Box::new(shiplog_render_md::MarkdownRenderer::default()));
791        let clusterer: &'static dyn shiplog_ports::WorkstreamClusterer =
792            Box::leak(Box::new(RepoClusterer));
793        let redactor: &'static dyn shiplog_ports::Redactor = Box::leak(Box::new(
794            shiplog_redact::DeterministicRedactor::new(b"test-key"),
795        ));
796        Engine::new(renderer, clusterer, redactor)
797    }
798
799    #[test]
800    fn run_creates_expected_output_files() {
801        let dir = tempfile::tempdir().unwrap();
802        let out_dir = dir.path().join("test_run_1");
803
804        let engine = test_engine();
805        let ingest = test_ingest();
806
807        let (outputs, _) = engine
808            .run(
809                ingest,
810                "tester",
811                "2025-01-01..2025-02-01",
812                &out_dir,
813                false,
814                &BundleProfile::Internal,
815            )
816            .unwrap();
817
818        assert!(outputs.packet_md.exists(), "packet.md missing");
819        assert!(
820            outputs.ledger_events_jsonl.exists(),
821            "ledger.events.jsonl missing"
822        );
823        assert!(
824            outputs.coverage_manifest_json.exists(),
825            "coverage.manifest.json missing"
826        );
827        assert!(
828            outputs.bundle_manifest_json.exists(),
829            "bundle.manifest.json missing"
830        );
831        assert!(
832            out_dir
833                .join(DIR_PROFILES)
834                .join(PROFILE_MANAGER)
835                .join(FILE_PACKET_MD)
836                .exists(),
837            "manager profile missing"
838        );
839        assert!(
840            out_dir
841                .join(DIR_PROFILES)
842                .join(PROFILE_PUBLIC)
843                .join(FILE_PACKET_MD)
844                .exists(),
845            "public profile missing"
846        );
847    }
848
849    #[test]
850    fn run_with_zip_creates_archive() {
851        let dir = tempfile::tempdir().unwrap();
852        let out_dir = dir.path().join("test_run_zip");
853
854        let engine = test_engine();
855        let ingest = test_ingest();
856
857        let (outputs, _) = engine
858            .run(
859                ingest,
860                "tester",
861                "2025-01-01..2025-02-01",
862                &out_dir,
863                true,
864                &BundleProfile::Internal,
865            )
866            .unwrap();
867
868        assert!(
869            outputs.zip_path.is_some(),
870            "zip_path should be Some when zip=true"
871        );
872        assert!(
873            outputs.zip_path.as_ref().unwrap().exists(),
874            "zip file missing"
875        );
876    }
877
878    #[test]
879    fn run_with_profile_rendering_disabled_skips_share_profiles() {
880        let dir = tempfile::tempdir().unwrap();
881        let out_dir = dir.path().join("test_run_internal_only");
882
883        let engine = test_engine().with_profile_rendering(false);
884        let ingest = test_ingest();
885
886        engine
887            .run(
888                ingest,
889                "tester",
890                "2025-01-01..2025-02-01",
891                &out_dir,
892                false,
893                &BundleProfile::Internal,
894            )
895            .unwrap();
896
897        assert!(
898            !out_dir
899                .join(DIR_PROFILES)
900                .join(PROFILE_MANAGER)
901                .join(FILE_PACKET_MD)
902                .exists(),
903            "manager profile should not be written"
904        );
905        assert!(
906            !out_dir
907                .join(DIR_PROFILES)
908                .join(PROFILE_PUBLIC)
909                .join(FILE_PACKET_MD)
910                .exists(),
911            "public profile should not be written"
912        );
913    }
914
915    #[test]
916    fn manager_bundle_requires_profile_rendering() {
917        let dir = tempfile::tempdir().unwrap();
918        let out_dir = dir.path().join("test_run_manager_without_profiles");
919
920        let engine = test_engine().with_profile_rendering(false);
921        let ingest = test_ingest();
922
923        let err = engine
924            .run(
925                ingest,
926                "tester",
927                "2025-01-01..2025-02-01",
928                &out_dir,
929                false,
930                &BundleProfile::Manager,
931            )
932            .unwrap_err();
933
934        assert!(
935            format!("{err:#}")
936                .contains("manager bundle profile requires manager/public profile rendering"),
937            "unexpected error: {err:#}"
938        );
939    }
940
941    #[test]
942    fn zip_path_internal_uses_plain_extension() {
943        let p = zip_path_for_profile(Path::new("/tmp/run_123"), "internal");
944        assert_eq!(p, Path::new("/tmp/run_123.zip"));
945    }
946
947    #[test]
948    fn zip_path_manager_includes_profile_name() {
949        let p = zip_path_for_profile(Path::new("/tmp/run_123"), "manager");
950        assert_eq!(p, Path::new("/tmp/run_123.manager.zip"));
951    }
952}