use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, Duration, Months, NaiveDate, Utc};
use clap::{Args, Parser, Subcommand, ValueEnum};
use regex::{Regex, RegexBuilder};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use shiplog_cache::ApiCache;
use shiplog_engine::{ConflictResolution, Engine, WorkstreamSource};
use shiplog_ids::{EventId, WorkstreamId};
use shiplog_ingest_git::LocalGitIngestor;
use shiplog_ingest_github::GithubIngestor;
use shiplog_ingest_gitlab::{GitlabIngestor, MrState};
use shiplog_ingest_jira::{IssueStatus, JiraIngestor};
use shiplog_ingest_json::JsonIngestor;
use shiplog_ingest_linear::{IssueStatus as LinearIssueStatus, LinearIngestor};
use shiplog_ingest_manual::{
ManualIngestor, create_empty_file, read_manual_events, write_manual_events,
};
use shiplog_ports::{IngestOutput, Ingestor, Redactor, Renderer};
use shiplog_redact::DeterministicRedactor;
use shiplog_render_md::{
AppendixMode, MarkdownRenderOptions, MarkdownRenderer, SectionOrder, format_receipt_markdown,
};
use shiplog_schema::{
bundle::BundleProfile,
coverage::{CoverageManifest, TimeWindow},
event::{EventEnvelope, EventPayload},
event::{Link, ManualDate, ManualEventEntry, ManualEventType},
workstream::{Workstream, WorkstreamStats, WorkstreamsFile},
};
use shiplog_workstreams::{RepoClusterer, WORKSTREAM_RECEIPT_RENDER_LIMIT};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(name = "shiplog", version)]
#[command(about = "Generate self-review packets with receipts + coverage.", long_about = None)]
struct Cli {
#[command(subcommand)]
cmd: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Init {
#[arg(long = "source", value_enum)]
sources: Vec<InitSource>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
},
Doctor {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
#[arg(long = "source", value_enum)]
sources: Vec<InitSource>,
},
Intake(IntakeArgs),
Config {
#[command(subcommand)]
cmd: ConfigCommand,
},
Cache {
#[command(subcommand)]
cmd: CacheCommand,
},
Identify {
#[command(subcommand)]
cmd: IdentifyCommand,
},
Journal {
#[command(subcommand)]
cmd: JournalCommand,
},
Collect {
#[command(subcommand)]
source: CollectSource,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
zip: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long)]
bundle_profile: Option<BundleProfile>,
#[arg(long)]
regen: bool,
#[arg(long)]
llm_cluster: bool,
#[arg(long, default_value = "https://api.openai.com/v1/chat/completions")]
llm_api_endpoint: String,
#[arg(long, default_value = "gpt-4o-mini")]
llm_model: String,
#[arg(long)]
llm_api_key: Option<String>,
},
Render {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long, default_value = "user")]
user: String,
#[arg(long, default_value = "window")]
window_label: String,
#[arg(long)]
redact_key: Option<String>,
#[arg(long, default_value = "internal")]
bundle_profile: BundleProfile,
#[arg(long, value_enum, default_value = "packet")]
mode: RenderPacketMode,
#[arg(long)]
receipt_limit: Option<usize>,
#[arg(long, value_enum)]
appendix: Option<RenderAppendixMode>,
#[arg(long)]
zip: bool,
},
Share {
#[command(subcommand)]
cmd: ShareCommand,
},
Refresh {
#[command(subcommand)]
source: Source,
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run_dir: Option<PathBuf>,
#[arg(long)]
zip: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long, default_value = "internal")]
bundle_profile: BundleProfile,
},
Workstreams {
#[command(subcommand)]
cmd: WorkstreamsCommand,
},
Runs {
#[command(subcommand)]
cmd: RunsCommand,
},
Review {
#[command(subcommand)]
cmd: Option<ReviewCommand>,
#[command(flatten)]
options: ReviewOptions,
},
Open {
#[command(subcommand)]
cmd: OpenCommand,
},
Merge {
#[arg(long = "input", required = true)]
inputs: Vec<PathBuf>,
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long, value_enum, default_value = "prefer-most-recent")]
conflict: MergeConflict,
#[arg(long)]
user: Option<String>,
#[arg(long)]
window_label: Option<String>,
#[arg(long)]
zip: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long, default_value = "internal")]
bundle_profile: BundleProfile,
#[arg(long)]
regen: bool,
},
Import {
#[arg(long)]
dir: PathBuf,
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long, default_value = "user")]
user: String,
#[arg(long, default_value = "window")]
window_label: String,
#[arg(long)]
redact_key: Option<String>,
#[arg(long, default_value = "internal")]
bundle_profile: BundleProfile,
#[arg(long)]
zip: bool,
#[arg(long)]
regen: bool,
#[arg(long)]
llm_cluster: bool,
#[arg(long, default_value = "https://api.openai.com/v1/chat/completions")]
llm_api_endpoint: String,
#[arg(long, default_value = "gpt-4o-mini")]
llm_model: String,
#[arg(long)]
llm_api_key: Option<String>,
},
Run {
#[command(subcommand)]
source: Source,
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
zip: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long, default_value = "internal")]
bundle_profile: BundleProfile,
#[arg(long)]
llm_cluster: bool,
#[arg(long, default_value = "https://api.openai.com/v1/chat/completions")]
llm_api_endpoint: String,
#[arg(long, default_value = "gpt-4o-mini")]
llm_model: String,
#[arg(long)]
llm_api_key: Option<String>,
},
}
#[derive(Args, Debug)]
struct ReviewOptions {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
period: Option<String>,
#[arg(long)]
strict: bool,
}
#[derive(Subcommand, Debug)]
enum ReviewCommand {
Weekly {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
strict: bool,
},
Fixups {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
commands_only: bool,
#[arg(long)]
journal_template: bool,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommand {
Validate {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
},
Explain {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
},
Migrate {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand, Debug)]
enum CacheCommand {
Stats(CacheArgs),
Inspect(CacheArgs),
Clean(CacheCleanArgs),
}
#[derive(Subcommand, Debug)]
enum IdentifyCommand {
Jira {
#[arg(long)]
instance: String,
#[arg(long)]
auth_user: Option<String>,
#[arg(long)]
token: Option<String>,
},
Linear {
#[arg(long)]
api_key: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum JournalCommand {
Add(JournalAddArgs),
List(JournalListArgs),
Edit(JournalEditArgs),
}
#[derive(Args, Debug)]
struct JournalAddArgs {
#[arg(long, default_value = MANUAL_EVENTS_FILENAME)]
events: PathBuf,
#[arg(long)]
id: Option<String>,
#[arg(long = "type", value_enum, default_value = "note")]
event_type: JournalEventType,
#[arg(long)]
date: Option<NaiveDate>,
#[arg(long)]
start: Option<NaiveDate>,
#[arg(long)]
end: Option<NaiveDate>,
#[arg(long)]
title: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
workstream: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "receipt", value_name = "LABEL=URL")]
receipts: Vec<String>,
#[arg(long)]
impact: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Args, Debug)]
struct JournalListArgs {
#[arg(long, default_value = MANUAL_EVENTS_FILENAME)]
events: PathBuf,
#[arg(long)]
workstream: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
}
#[derive(Args, Debug)]
struct JournalEditArgs {
#[arg(long, default_value = MANUAL_EVENTS_FILENAME)]
events: PathBuf,
#[arg(long)]
id: String,
#[arg(long = "type", value_enum)]
event_type: Option<JournalEventType>,
#[arg(long)]
date: Option<NaiveDate>,
#[arg(long)]
start: Option<NaiveDate>,
#[arg(long)]
end: Option<NaiveDate>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
workstream: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "receipt", value_name = "LABEL=URL")]
receipts: Vec<String>,
#[arg(long)]
impact: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum JournalEventType {
Note,
Incident,
Design,
Mentoring,
Launch,
Migration,
Review,
Other,
}
impl From<JournalEventType> for ManualEventType {
fn from(value: JournalEventType) -> Self {
match value {
JournalEventType::Note => Self::Note,
JournalEventType::Incident => Self::Incident,
JournalEventType::Design => Self::Design,
JournalEventType::Mentoring => Self::Mentoring,
JournalEventType::Launch => Self::Launch,
JournalEventType::Migration => Self::Migration,
JournalEventType::Review => Self::Review,
JournalEventType::Other => Self::Other,
}
}
}
#[derive(Args, Debug)]
struct CacheArgs {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long = "source", value_enum)]
sources: Vec<CacheSource>,
}
#[derive(Args, Debug)]
struct CacheCleanArgs {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long = "source", value_enum)]
sources: Vec<CacheSource>,
#[arg(long)]
older_than: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum CacheSource {
Github,
Gitlab,
Jira,
Linear,
}
impl CacheSource {
fn all() -> [Self; 4] {
[Self::Github, Self::Gitlab, Self::Jira, Self::Linear]
}
fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::Gitlab => "gitlab",
Self::Jira => "jira",
Self::Linear => "linear",
}
}
fn db_filename(self) -> &'static str {
match self {
Self::Github => "github-api-cache.db",
Self::Gitlab => "gitlab-api-cache.db",
Self::Jira => "jira-api-cache.db",
Self::Linear => "linear-api-cache.db",
}
}
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
enum InitSource {
Github,
Gitlab,
Jira,
Linear,
Git,
Json,
Manual,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
enum MergeConflict {
#[value(name = "prefer-first")]
First,
#[value(name = "prefer-most-recent")]
MostRecent,
#[value(name = "prefer-most-complete")]
MostComplete,
}
impl From<MergeConflict> for ConflictResolution {
fn from(value: MergeConflict) -> Self {
match value {
MergeConflict::First => Self::PreferFirst,
MergeConflict::MostRecent => Self::PreferMostRecent,
MergeConflict::MostComplete => Self::PreferMostComplete,
}
}
}
impl MergeConflict {
fn as_str(self) -> &'static str {
match self {
Self::First => "prefer-first",
Self::MostRecent => "prefer-most-recent",
Self::MostComplete => "prefer-most-complete",
}
}
}
#[derive(Subcommand, Debug)]
enum RunsCommand {
List {
#[arg(long, default_value = "./out")]
out: PathBuf,
},
Show {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
},
Compare {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
from: String,
#[arg(long)]
to: String,
},
}
#[derive(Subcommand, Debug)]
enum OpenCommand {
Packet {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
print_path: bool,
},
Workstreams {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
print_path: bool,
},
#[command(alias = "report")]
IntakeReport {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
print_path: bool,
},
Out {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
print_path: bool,
},
}
#[derive(Subcommand, Debug)]
enum WorkstreamsCommand {
List {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
},
Validate {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
},
Rename {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
from: String,
#[arg(long)]
to: String,
},
Move {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
event: String,
#[arg(long)]
to: String,
},
Receipts {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
workstream: String,
},
Receipt {
#[command(subcommand)]
cmd: WorkstreamReceiptCommand,
},
Create {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
title: String,
},
Delete {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
workstream: String,
#[arg(long)]
move_to: Option<String>,
#[arg(long)]
force: bool,
},
Split {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
from: String,
#[arg(long)]
to: String,
#[arg(long)]
matching: String,
#[arg(long)]
create: bool,
},
}
#[derive(Subcommand, Debug)]
enum WorkstreamReceiptCommand {
Add {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
workstream: String,
#[arg(long)]
event: String,
},
Remove {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
workstream: String,
#[arg(long)]
event: String,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum RenderPacketMode {
Packet,
Scaffold,
Receipts,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum RenderAppendixMode {
Full,
Summary,
None,
}
impl From<RenderAppendixMode> for AppendixMode {
fn from(value: RenderAppendixMode) -> Self {
match value {
RenderAppendixMode::Full => Self::Full,
RenderAppendixMode::Summary => Self::Summary,
RenderAppendixMode::None => Self::None,
}
}
}
#[derive(Subcommand, Debug)]
enum ShareCommand {
Manager(ShareOptions),
Public(ShareOptions),
Verify {
#[command(subcommand)]
cmd: ShareVerifyCommand,
},
}
#[derive(Args, Debug)]
struct ShareOptions {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long)]
zip: bool,
}
#[derive(Subcommand, Debug)]
enum ShareVerifyCommand {
Manager(ShareVerifyOptions),
Public(ShareVerifyOptions),
}
#[derive(Args, Debug)]
struct ShareVerifyOptions {
#[arg(long, default_value = "./out")]
out: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
redact_key: Option<String>,
#[arg(long)]
strict: bool,
}
#[derive(Subcommand, Debug, Clone)]
enum CollectSource {
Multi {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
#[command(flatten)]
window: ConfigWindowArgs,
#[arg(long, value_enum, default_value = "prefer-most-recent")]
conflict: MergeConflict,
},
#[command(flatten)]
Source(Source),
}
#[derive(Subcommand, Debug, Clone)]
enum Source {
Github {
#[arg(long)]
user: Option<String>,
#[arg(long)]
me: bool,
#[command(flatten)]
window: DateArgs,
#[arg(long, default_value = "merged")]
mode: String,
#[arg(long)]
include_reviews: bool,
#[arg(long)]
no_details: bool,
#[arg(long, default_value_t = 0)]
throttle_ms: u64,
#[arg(long)]
token: Option<String>,
#[arg(long, default_value = "https://api.github.com")]
api_base: String,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_cache: bool,
},
Gitlab {
#[arg(long)]
user: Option<String>,
#[arg(long)]
me: bool,
#[command(flatten)]
window: DateArgs,
#[arg(long, default_value = "merged")]
state: String,
#[arg(long, default_value = "gitlab.com")]
instance: String,
#[arg(long)]
include_reviews: bool,
#[arg(long, default_value_t = 0)]
throttle_ms: u64,
#[arg(long)]
token: Option<String>,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_cache: bool,
},
Jira {
#[arg(long)]
user: String,
#[arg(long)]
auth_user: Option<String>,
#[command(flatten)]
window: DateArgs,
#[arg(long, default_value = "done")]
status: String,
#[arg(long)]
instance: String,
#[arg(long, default_value_t = 0)]
throttle_ms: u64,
#[arg(long)]
token: Option<String>,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_cache: bool,
},
Linear {
#[arg(long)]
user_id: String,
#[command(flatten)]
window: DateArgs,
#[arg(long, default_value = "done")]
status: String,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 0)]
throttle_ms: u64,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_cache: bool,
},
Json {
#[arg(long)]
events: PathBuf,
#[arg(long)]
coverage: PathBuf,
#[arg(long, default_value = "user")]
user: String,
#[arg(long, default_value = "window")]
window_label: String,
},
Manual {
#[arg(long)]
events: PathBuf,
#[arg(long, default_value = "user")]
user: String,
#[command(flatten)]
window: DateArgs,
},
Git {
#[arg(long)]
repo: PathBuf,
#[command(flatten)]
window: DateArgs,
#[arg(long)]
author: Option<String>,
#[arg(long)]
include_merges: bool,
},
}
#[derive(Args, Debug, Clone, Default)]
struct DateArgs {
#[arg(long)]
since: Option<NaiveDate>,
#[arg(long)]
until: Option<NaiveDate>,
#[arg(long)]
last_6_months: bool,
#[arg(long)]
last_quarter: bool,
#[arg(long)]
year: Option<i32>,
}
#[derive(Args, Debug, Clone)]
struct IntakeArgs {
#[arg(long, default_value = CONFIG_FILENAME)]
config: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long = "source", value_enum)]
sources: Vec<InitSource>,
#[arg(long)]
profile: Option<BundleProfile>,
#[arg(long)]
redact_key: Option<String>,
#[arg(long)]
no_open: bool,
#[arg(long)]
explain: bool,
#[arg(long, value_enum, default_value = "prefer-most-recent")]
conflict: MergeConflict,
#[command(flatten)]
window: ConfigWindowArgs,
}
#[derive(Args, Debug, Clone, Default)]
struct ConfigWindowArgs {
#[command(flatten)]
dates: DateArgs,
#[arg(long)]
period: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedWindow {
since: NaiveDate,
until: NaiveDate,
label: WindowLabel,
period: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindowLabel {
Explicit,
LastSixMonths,
LastQuarter,
Year(i32),
}
const CONFIG_FILENAME: &str = "shiplog.toml";
const MANUAL_EVENTS_FILENAME: &str = "manual_events.yaml";
const CURRENT_CONFIG_VERSION: i64 = 1;
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ShiplogConfig {
shiplog: ConfigMetadata,
defaults: ConfigDefaults,
periods: BTreeMap<String, ConfigPeriod>,
user: ConfigUser,
sources: ConfigSources,
redaction: ConfigRedaction,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigMetadata {
config_version: Option<i64>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigDefaults {
out: Option<PathBuf>,
window: Option<String>,
profile: Option<String>,
include_reviews: Option<bool>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigPeriod {
since: Option<NaiveDate>,
until: Option<NaiveDate>,
preset: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigUser {
label: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigSources {
github: Option<ConfigGithubSource>,
gitlab: Option<ConfigGitlabSource>,
jira: Option<ConfigJiraSource>,
linear: Option<ConfigLinearSource>,
git: Option<ConfigGitSource>,
json: Option<ConfigJsonSource>,
manual: Option<ConfigManualSource>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigGithubSource {
enabled: bool,
user: Option<String>,
me: bool,
mode: Option<String>,
include_reviews: Option<bool>,
no_details: bool,
throttle_ms: u64,
api_base: Option<String>,
cache_dir: Option<PathBuf>,
no_cache: bool,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigGitlabSource {
enabled: bool,
user: Option<String>,
me: bool,
instance: Option<String>,
state: Option<String>,
include_reviews: Option<bool>,
throttle_ms: u64,
cache_dir: Option<PathBuf>,
no_cache: bool,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigJiraSource {
enabled: bool,
user: Option<String>,
auth_user: Option<String>,
auth_user_env: Option<String>,
instance: Option<String>,
status: Option<String>,
throttle_ms: u64,
cache_dir: Option<PathBuf>,
no_cache: bool,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigLinearSource {
enabled: bool,
user_id: Option<String>,
status: Option<String>,
project: Option<String>,
throttle_ms: u64,
cache_dir: Option<PathBuf>,
no_cache: bool,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigGitSource {
enabled: bool,
repo: Option<PathBuf>,
author: Option<String>,
include_merges: bool,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigJsonSource {
enabled: bool,
events: Option<PathBuf>,
coverage: Option<PathBuf>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigManualSource {
enabled: bool,
events: Option<PathBuf>,
user: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
struct ConfigRedaction {
key_env: Option<String>,
}
#[derive(Debug)]
struct ConfiguredSourceFailure {
name: String,
error: String,
}
#[derive(Debug, Default)]
struct IntakeSourcePlan {
failures: Vec<ConfiguredSourceFailure>,
explanations: Vec<IntakeSourceExplanation>,
}
#[derive(Debug)]
struct IntakeSourceExplanation {
name: String,
decision: IntakeSourceDecision,
reason: String,
}
struct IntakeSourceHint {
label: &'static str,
lines: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
enum IntakeSourceDecision {
Included,
Skipped,
}
#[derive(Debug, Serialize)]
struct IntakeReport {
schema_version: u8,
run_id: String,
readiness: String,
config_path: String,
out_dir: String,
run_dir: String,
packet_path: String,
period: Option<String>,
window: IntakeReportWindow,
reports: IntakeReportFiles,
included_sources: Vec<IntakeReportIncludedSource>,
skipped_sources: Vec<IntakeReportSkippedSource>,
source_decisions: Vec<IntakeReportSourceDecision>,
repair_sources: Vec<IntakeReportRepairSource>,
curation_notes: Vec<String>,
good: Vec<String>,
needs_attention: Vec<String>,
evidence_debt: Vec<IntakeReportEvidenceDebt>,
top_fixups: Vec<IntakeReportFixup>,
journal_suggestions: Vec<String>,
share_commands: Vec<String>,
next_commands: Vec<String>,
artifacts: Vec<IntakeReportArtifact>,
}
#[derive(Debug, Serialize)]
struct IntakeReportFiles {
markdown: String,
json: String,
}
#[derive(Debug, Serialize)]
struct IntakeReportWindow {
since: String,
until: String,
label: String,
}
#[derive(Debug, Serialize)]
struct IntakeReportIncludedSource {
source: String,
event_count: usize,
summary: String,
}
#[derive(Debug, Serialize)]
struct IntakeReportSkippedSource {
source: String,
reason: String,
}
#[derive(Debug, Serialize)]
struct IntakeReportSourceDecision {
source: String,
decision: String,
reason: String,
hint_label: Option<String>,
hint_lines: Vec<String>,
}
#[derive(Debug, Serialize)]
struct IntakeReportRepairSource {
source: String,
reason: String,
commands: Vec<String>,
}
#[derive(Debug, Serialize)]
struct IntakeReportEvidenceDebt {
severity: String,
kind: String,
summary: String,
detail: Option<String>,
next_step: Option<String>,
}
#[derive(Debug, Serialize)]
struct IntakeReportFixup {
title: String,
detail: Option<String>,
command: String,
}
#[derive(Debug, Serialize)]
struct IntakeReportArtifact {
label: String,
path: String,
}
#[derive(Debug)]
struct ConfiguredSourceOutputs {
successes: Vec<(String, IngestOutput)>,
failures: Vec<ConfiguredSourceFailure>,
}
#[derive(Debug)]
struct ConfiguredRunResult {
configured: ConfiguredSourceOutputs,
outputs: shiplog_engine::RunOutputs,
ws_source: WorkstreamSource,
run_id: String,
window: ResolvedWindow,
prior_curation: Option<PriorCuration>,
}
#[derive(Debug)]
struct PriorCuration {
source_run_dir: PathBuf,
source_path: PathBuf,
destination_path: PathBuf,
copied: bool,
}
#[derive(Debug, Clone)]
struct RedactionKey {
key: Option<String>,
}
impl RedactionKey {
fn resolve(redact_key: Option<String>, bundle_profile: &BundleProfile) -> Result<Self> {
Self::resolve_with_env(redact_key, bundle_profile, "SHIPLOG_REDACT_KEY")
}
fn resolve_for_share(
redact_key: Option<String>,
bundle_profile: &BundleProfile,
) -> Result<Self> {
let key_env = "SHIPLOG_REDACT_KEY";
let key = redact_key.or_else(|| std::env::var(key_env).ok());
if key.is_none() {
anyhow::bail!(share_command_key_error(bundle_profile, key_env));
}
Ok(Self { key })
}
fn resolve_with_env(
redact_key: Option<String>,
bundle_profile: &BundleProfile,
key_env: &str,
) -> Result<Self> {
let key = redact_key.or_else(|| std::env::var(key_env).ok());
if key.is_none() && !matches!(bundle_profile, BundleProfile::Internal) {
anyhow::bail!(share_profile_key_error(bundle_profile, key_env));
}
Ok(Self { key })
}
fn engine_key(&self) -> &str {
self.key.as_deref().unwrap_or("")
}
fn render_profiles(&self) -> bool {
self.key.is_some()
}
}
fn share_profile_key_error(bundle_profile: &BundleProfile, key_env: &str) -> String {
format!(
"{bundle_profile} profile requires --redact-key or {key_env}.\n\
Try:\n\
export {key_env}=replace-with-a-stable-secret\n\
rerun this command with --bundle-profile {bundle_profile}\n\
For an internal-only packet, use --bundle-profile internal."
)
}
fn share_command_key_error(bundle_profile: &BundleProfile, key_env: &str) -> String {
format!(
"{bundle_profile} share requires --redact-key or {key_env}.\n\
Try:\n\
export {key_env}=replace-with-a-stable-secret\n\
shiplog share {bundle_profile} --latest\n\
For an internal-only packet, use `shiplog render --bundle-profile internal`."
)
}
impl ResolvedWindow {
fn window_label(&self) -> String {
let label = match self.label {
WindowLabel::Explicit => format!("{}..{}", self.since, self.until),
WindowLabel::LastSixMonths => {
format!("last-6-months ({}..{})", self.since, self.until)
}
WindowLabel::LastQuarter => {
format!("last-quarter ({}..{})", self.since, self.until)
}
WindowLabel::Year(year) => format!("{year} ({}..{})", self.since, self.until),
};
if let Some(period) = &self.period {
format!("{period} ({}..{})", self.since, self.until)
} else {
label
}
}
fn with_period(mut self, period: impl Into<String>) -> Self {
self.period = Some(period.into());
self
}
}
fn resolve_date_window(args: DateArgs) -> Result<ResolvedWindow> {
resolve_date_window_for_today(args, Utc::now().date_naive())
}
fn resolve_date_window_for_today(args: DateArgs, today: NaiveDate) -> Result<ResolvedWindow> {
match (args.since, args.until) {
(Some(since), Some(until)) => return checked_window(since, until, WindowLabel::Explicit),
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!("provide both --since and --until, or use a date preset")
}
(None, None) => {}
}
let preset_count = usize::from(args.last_6_months)
+ usize::from(args.last_quarter)
+ usize::from(args.year.is_some());
if preset_count > 1 {
anyhow::bail!("choose only one date preset: --last-6-months, --last-quarter, or --year")
}
if let Some(year) = args.year {
let since = NaiveDate::from_ymd_opt(year, 1, 1)
.ok_or_else(|| anyhow::anyhow!("invalid --year value: {year}"))?;
let until = NaiveDate::from_ymd_opt(year + 1, 1, 1)
.ok_or_else(|| anyhow::anyhow!("invalid --year value: {year}"))?;
return checked_window(since, until, WindowLabel::Year(year));
}
if args.last_quarter {
let start_of_current_quarter = quarter_start(today.year(), today.month())?;
let previous_quarter_anchor = start_of_current_quarter
.checked_sub_months(Months::new(3))
.ok_or_else(|| anyhow::anyhow!("could not resolve --last-quarter"))?;
return checked_window(
previous_quarter_anchor,
start_of_current_quarter,
WindowLabel::LastQuarter,
);
}
let since = today
.checked_sub_months(Months::new(6))
.ok_or_else(|| anyhow::anyhow!("could not resolve --last-6-months"))?;
checked_window(since, today, WindowLabel::LastSixMonths)
}
fn checked_window(
since: NaiveDate,
until: NaiveDate,
label: WindowLabel,
) -> Result<ResolvedWindow> {
if since >= until {
anyhow::bail!("date window must satisfy --since < --until")
}
Ok(ResolvedWindow {
since,
until,
label,
period: None,
})
}
fn quarter_start(year: i32, month: u32) -> Result<NaiveDate> {
let start_month = match month {
1..=3 => 1,
4..=6 => 4,
7..=9 => 7,
10..=12 => 10,
_ => anyhow::bail!("invalid month while resolving quarter: {month}"),
};
NaiveDate::from_ymd_opt(year, start_month, 1)
.ok_or_else(|| anyhow::anyhow!("invalid quarter start for {year}-{start_month:02}"))
}
fn run_init(sources: Vec<InitSource>, dry_run: bool, force: bool) -> Result<()> {
let selected = selected_init_sources(&sources);
let config = render_init_config(&selected);
let manual_events = render_manual_events_template();
if dry_run {
println!("Would write {CONFIG_FILENAME}:\n\n{config}");
println!("Would write {MANUAL_EVENTS_FILENAME}:\n\n{manual_events}");
return Ok(());
}
let config_path = Path::new(CONFIG_FILENAME);
let manual_events_path = Path::new(MANUAL_EVENTS_FILENAME);
ensure_init_files_available(&[config_path, manual_events_path], force)?;
write_init_file(config_path, &config)?;
write_init_file(manual_events_path, &manual_events)?;
println!("Initialized shiplog:");
println!(" {CONFIG_FILENAME}");
println!(" {MANUAL_EVENTS_FILENAME}");
println!();
println!("Next:");
println!(" edit {CONFIG_FILENAME}");
for env_var in init_env_vars(&selected) {
println!(" export {env_var}=...");
}
println!(" {}", init_next_command(&selected));
Ok(())
}
fn run_intake(args: IntakeArgs) -> Result<()> {
let config_setup = ensure_intake_config(&args.config, &args.sources)?;
let mut config_model = load_shiplog_config(&args.config)?;
ensure_supported_config_version(&config_model)?;
let base_dir = config_base_dir(&args.config);
let out = args
.out
.clone()
.unwrap_or_else(|| config_default_out(&config_model, &base_dir));
let bundle_profile = args
.profile
.clone()
.map(Ok)
.unwrap_or_else(|| doctor_config_profile(config_model.defaults.profile.as_deref()))?;
let redaction_key = RedactionKey::resolve_with_env(
args.redact_key.clone(),
&bundle_profile,
&config_redaction_key_env(&config_model),
)?;
let explicit_sources = dedupe_sources(&args.sources);
let mut intake_plan =
prepare_intake_sources(&args.config, &mut config_model, &explicit_sources)?;
intake_plan
.explanations
.splice(0..0, config_setup.source_explanations);
let window = resolve_multi_window(args.window.clone(), &config_model)?;
let mut configured =
collect_configured_sources(&args.config, &config_model, window.clone(), &out)
.with_context(|| {
format!(
"collect usable intake sources from {}",
args.config.display()
)
})?;
intake_plan.failures.append(&mut configured.failures);
configured.failures = intake_plan.failures;
let clusterer = build_clusterer(false, "", "", None);
let (engine, redactor) = create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
let result = run_configured_multi_pipeline(
&config_model,
&out,
window,
&bundle_profile,
args.conflict,
configured,
false,
true,
false,
&engine,
redactor,
)?;
let report = build_intake_report(&result, &out, &args.config, &intake_plan.explanations)?;
write_intake_report(&result.outputs.out_dir, &report)?;
println!("Review intake complete.");
if config_setup.created {
println!("Config: created {}", args.config.display());
} else {
println!("Config: {}", args.config.display());
}
println!("Run: {}", result.run_id);
println!("Packet: {}", result.outputs.packet_md.display());
println!();
println!("Collected:");
for (name, ingest) in &result.configured.successes {
println!(
"- {}: success, {}",
display_source_label(name),
event_count_phrase(ingest.events.len())
);
}
if result.configured.failures.is_empty() {
println!("Skipped:");
println!("- None");
} else {
println!("Skipped:");
for failure in &result.configured.failures {
println!(
"- {}: {}",
display_source_label(&failure.name),
failure.error
);
}
}
if args.explain {
println!();
print_intake_explanations(&intake_plan.explanations);
}
println!();
println!("Artifacts:");
print_outputs(&result.outputs, result.ws_source.clone());
println!("- {}", report.reports.markdown);
println!("- {}", report.reports.json);
println!();
print_review(&result.outputs.out_dir, false)?;
println!();
print_intake_readiness_report(&report);
if args.no_open {
println!();
println!("Open later:");
println!(
"1. shiplog open packet --out {} --run {} --print-path",
quote_cli_value(&out.display().to_string()),
result.run_id
);
} else {
println!();
open_or_print_path(&result.outputs.packet_md, false)?;
}
Ok(())
}
fn build_intake_report(
result: &ConfiguredRunResult,
out_dir: &Path,
config_path: &Path,
explanations: &[IntakeSourceExplanation],
) -> Result<IntakeReport> {
let ingest = load_run_ingest(&result.outputs.out_dir)
.with_context(|| format!("load intake run {}", result.outputs.out_dir.display()))?;
let coverage = ingest.coverage;
let events = ingest.events;
let run_id = result.run_id.clone();
let skipped_sources = configured_source_skips(&coverage.warnings);
let (workstreams, _, _) = load_effective_workstreams_for_run(&result.outputs.out_dir)?;
let validation_errors = validate_workstreams_against_events(&workstreams, &events);
let signals = workstream_quality_signals(&workstreams, &events);
let mut good = Vec::new();
for (name, ingest) in &result.configured.successes {
good.push(format!(
"{} collected {}",
display_source_label(name),
event_count_phrase(ingest.events.len())
));
}
good.push("Packet rendered".to_string());
good.push("Evidence ledger written".to_string());
good.push("Coverage manifest written".to_string());
good.push("Review inspection completed".to_string());
let mut attention = Vec::new();
for failure in &result.configured.failures {
attention.push(format!(
"{} skipped: {}",
display_source_label(&failure.name),
failure.error
));
}
if coverage.completeness != shiplog_schema::coverage::Completeness::Complete {
attention.push(format!(
"Coverage is {}; skipped or incomplete sources are recorded.",
coverage.completeness
));
}
let gap_count = coverage_gap_count(&coverage);
if gap_count > 0 {
attention.push(format!("{gap_count} coverage gap(s) should be reviewed."));
}
if events.is_empty() {
attention.push("No events collected; add manual evidence or enable a source.".to_string());
}
if !validation_errors.is_empty() {
attention.push(format!(
"{} workstream validation issue(s) need repair.",
validation_errors.len()
));
}
if !signals.no_receipt_workstreams.is_empty() {
attention.push(format!(
"{} workstream(s) have no selected receipts.",
signals.no_receipt_workstreams.len()
));
}
if !signals.broad_workstreams.is_empty() {
attention.push(format!(
"{} broad workstream(s) may need splitting.",
signals.broad_workstreams.len()
));
}
if !signals.manual_context_workstreams.is_empty() {
attention.push(format!(
"{} broad workstream(s) need outcome context.",
signals.manual_context_workstreams.len()
));
}
let readiness = if !validation_errors.is_empty() {
"Needs repair"
} else if events.is_empty() {
"Needs evidence"
} else if attention.is_empty() {
"Ready for review"
} else {
"Needs curation"
};
let evidence_debt = detect_evidence_debt(EvidenceDebtInput {
run_id: &run_id,
coverage: &coverage,
events: &events,
skipped_sources: &skipped_sources,
workstreams: &workstreams,
validation_errors: &validation_errors,
signals: &signals,
});
let top_fixups = review_fixups(
&run_id,
out_dir,
&skipped_sources,
&validation_errors,
&signals,
);
let curation_notes = intake_curation_notes(result);
let next_commands = intake_readiness_next_steps(
&run_id,
out_dir,
config_path,
&result.configured.failures,
signals
.no_receipt_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
signals
.broad_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
signals
.manual_context_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
);
let out_arg = quote_cli_value(&out_dir.display().to_string());
let report_md = result.outputs.out_dir.join("intake.report.md");
let report_json = result.outputs.out_dir.join("intake.report.json");
let mut artifacts = vec![
IntakeReportArtifact {
label: "packet".to_string(),
path: result.outputs.packet_md.display().to_string(),
},
IntakeReportArtifact {
label: "ledger".to_string(),
path: result.outputs.ledger_events_jsonl.display().to_string(),
},
IntakeReportArtifact {
label: "coverage".to_string(),
path: result.outputs.coverage_manifest_json.display().to_string(),
},
IntakeReportArtifact {
label: format!("workstreams ({})", result.ws_source),
path: result.outputs.workstreams_yaml.display().to_string(),
},
IntakeReportArtifact {
label: "bundle manifest".to_string(),
path: result.outputs.bundle_manifest_json.display().to_string(),
},
IntakeReportArtifact {
label: "intake report markdown".to_string(),
path: report_md.display().to_string(),
},
IntakeReportArtifact {
label: "intake report json".to_string(),
path: report_json.display().to_string(),
},
];
if let Some(zip_path) = &result.outputs.zip_path {
artifacts.push(IntakeReportArtifact {
label: "zip bundle".to_string(),
path: zip_path.display().to_string(),
});
}
Ok(IntakeReport {
schema_version: 1,
run_id: run_id.clone(),
readiness: readiness.to_string(),
config_path: config_path.display().to_string(),
out_dir: out_dir.display().to_string(),
run_dir: result.outputs.out_dir.display().to_string(),
packet_path: result.outputs.packet_md.display().to_string(),
period: result.window.period.clone(),
window: IntakeReportWindow {
since: result.window.since.to_string(),
until: result.window.until.to_string(),
label: result.window.window_label(),
},
reports: IntakeReportFiles {
markdown: report_md.display().to_string(),
json: report_json.display().to_string(),
},
included_sources: result
.configured
.successes
.iter()
.map(|(name, ingest)| IntakeReportIncludedSource {
source: display_source_label(name),
event_count: ingest.events.len(),
summary: format!(
"{} collected {}",
display_source_label(name),
event_count_phrase(ingest.events.len())
),
})
.collect(),
skipped_sources: result
.configured
.failures
.iter()
.map(|failure| IntakeReportSkippedSource {
source: display_source_label(&failure.name),
reason: failure.error.clone(),
})
.collect(),
source_decisions: intake_source_decision_reports(explanations),
repair_sources: intake_repair_source_reports(explanations, &result.configured.failures),
curation_notes,
good,
needs_attention: attention,
evidence_debt: evidence_debt
.iter()
.map(|item| IntakeReportEvidenceDebt {
severity: item.severity.label().to_string(),
kind: item.kind.label().to_string(),
summary: item.summary.clone(),
detail: item.detail.clone(),
next_step: item.next_step.clone(),
})
.collect(),
top_fixups: top_fixups
.iter()
.take(5)
.map(|fixup| IntakeReportFixup {
title: fixup.title.clone(),
detail: fixup.detail.clone(),
command: fixup.command.clone(),
})
.collect(),
journal_suggestions: top_fixups
.iter()
.map(|fixup| fixup.command.as_str())
.filter(|command| command.starts_with("shiplog journal add "))
.map(str::to_string)
.collect(),
share_commands: vec![
format!("shiplog share manager --out {out_arg} --run {run_id}"),
format!("shiplog share public --out {out_arg} --run {run_id}"),
],
next_commands,
artifacts,
})
}
fn print_intake_readiness_report(report: &IntakeReport) {
println!("Intake readiness:");
println!("Packet readiness: {}", report.readiness);
println!();
println!("Good:");
for item in &report.good {
println!("- {item}");
}
println!();
println!("Needs attention:");
if report.needs_attention.is_empty() {
println!("- None");
} else {
for item in &report.needs_attention {
println!("- {item}");
}
}
if !report.repair_sources.is_empty() {
println!();
println!("Repair sources:");
for repair in &report.repair_sources {
println!("- {}: {}", repair.source, repair.reason);
for command in &repair.commands {
println!(" {command}");
}
}
}
if !report.curation_notes.is_empty() {
println!();
println!("Curation:");
for note in &report.curation_notes {
println!("- {note}");
}
}
println!();
println!("Next:");
for (idx, command) in report.next_commands.iter().enumerate() {
println!("{}. {}", idx + 1, command);
}
}
fn write_intake_report(run_dir: &Path, report: &IntakeReport) -> Result<()> {
let markdown = render_intake_report_markdown(report);
std::fs::write(run_dir.join("intake.report.md"), markdown)
.with_context(|| format!("write {}", run_dir.join("intake.report.md").display()))?;
let json = serde_json::to_string_pretty(report)?;
std::fs::write(run_dir.join("intake.report.json"), format!("{json}\n"))
.with_context(|| format!("write {}", run_dir.join("intake.report.json").display()))?;
Ok(())
}
fn render_intake_report_markdown(report: &IntakeReport) -> String {
let mut out = String::new();
out.push_str("# Review Intake Report\n\n");
out.push_str(&format!("Run: `{}`\n\n", report.run_id));
out.push_str(&format!("Packet readiness: **{}**\n\n", report.readiness));
out.push_str(&format!(
"Window: `{}`..`{}` ({})\n\n",
report.window.since, report.window.until, report.window.label
));
if let Some(period) = &report.period {
out.push_str(&format!("Period: `{period}`\n\n"));
}
out.push_str(&format!("Config: `{}`\n\n", report.config_path));
out.push_str(&format!("Packet: `{}`\n\n", report.packet_path));
out.push_str("## Included Sources\n\n");
if report.included_sources.is_empty() {
out.push_str("- None\n");
} else {
for source in &report.included_sources {
out.push_str(&format!(
"- {}: {}\n",
source.source,
event_count_phrase(source.event_count)
));
}
}
out.push('\n');
out.push_str("## Skipped Sources\n\n");
if report.skipped_sources.is_empty() {
out.push_str("- None\n");
} else {
for skipped in &report.skipped_sources {
out.push_str(&format!("- {}: {}\n", skipped.source, skipped.reason));
}
}
out.push('\n');
out.push_str("## Source Decisions\n\n");
if report.source_decisions.is_empty() {
out.push_str("- None\n");
} else {
for decision in &report.source_decisions {
out.push_str(&format!(
"- {}: {}, {}\n",
decision.source, decision.decision, decision.reason
));
if let Some(label) = &decision.hint_label {
out.push_str(&format!(" - {label}:\n"));
for line in &decision.hint_lines {
out.push_str(&format!(" - {line}\n"));
}
}
}
}
out.push('\n');
out.push_str("## Repair Sources\n\n");
if report.repair_sources.is_empty() {
out.push_str("- None\n");
} else {
for repair in &report.repair_sources {
out.push_str(&format!("- {}: {}\n", repair.source, repair.reason));
for command in &repair.commands {
out.push_str(&format!(" - {command}\n"));
}
}
}
out.push('\n');
out.push_str("## Curation Notes\n\n");
if report.curation_notes.is_empty() {
out.push_str("- None\n");
} else {
for note in &report.curation_notes {
out.push_str(&format!("- {note}\n"));
}
}
out.push('\n');
out.push_str("## Good\n\n");
for item in &report.good {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
out.push_str("## Needs Attention\n\n");
if report.needs_attention.is_empty() {
out.push_str("- None\n");
} else {
for item in &report.needs_attention {
out.push_str(&format!("- {item}\n"));
}
}
out.push('\n');
out.push_str("## Evidence Debt\n\n");
if report.evidence_debt.is_empty() {
out.push_str("- No obvious evidence debt detected.\n");
} else {
for item in &report.evidence_debt {
out.push_str(&format!(
"- [{}] {}: {}\n",
item.severity, item.kind, item.summary
));
if let Some(detail) = &item.detail {
out.push_str(&format!(" - Detail: {detail}\n"));
}
if let Some(next_step) = &item.next_step {
out.push_str(&format!(" - Next: {next_step}\n"));
}
}
}
out.push('\n');
out.push_str("## Top Fixups\n\n");
if report.top_fixups.is_empty() {
out.push_str("- No high-value fixups found.\n");
} else {
for (idx, fixup) in report.top_fixups.iter().enumerate() {
out.push_str(&format!("{}. {}\n", idx + 1, fixup.title));
if let Some(detail) = &fixup.detail {
out.push_str(&format!(" - {detail}\n"));
}
out.push_str(&format!(" - `{}`\n", fixup.command));
}
}
out.push('\n');
out.push_str("## Journal Suggestions\n\n");
if report.journal_suggestions.is_empty() {
out.push_str("- None\n");
} else {
for command in &report.journal_suggestions {
out.push_str(&format!("- `{command}`\n"));
}
}
out.push('\n');
out.push_str("## Share Commands\n\n");
for command in &report.share_commands {
out.push_str(&format!("- `{command}`\n"));
}
out.push_str(
"\nShare commands require `--redact-key` or `SHIPLOG_REDACT_KEY` at execution time.\n\n",
);
out.push_str("## Next Commands\n\n");
for (idx, command) in report.next_commands.iter().enumerate() {
out.push_str(&format!("{}. `{command}`\n", idx + 1));
}
out.push('\n');
out.push_str("## Artifacts\n\n");
for artifact in &report.artifacts {
out.push_str(&format!("- {}: `{}`\n", artifact.label, artifact.path));
}
out
}
fn intake_source_decision_reports(
explanations: &[IntakeSourceExplanation],
) -> Vec<IntakeReportSourceDecision> {
explanations
.iter()
.map(|explanation| {
let hint = intake_source_hint(explanation);
let (hint_label, hint_lines) = hint
.map(|hint| (Some(hint.label.to_string()), hint.lines))
.unwrap_or((None, Vec::new()));
IntakeReportSourceDecision {
source: display_source_label(&explanation.name),
decision: match explanation.decision {
IntakeSourceDecision::Included => "included".to_string(),
IntakeSourceDecision::Skipped => "skipped".to_string(),
},
reason: explanation.reason.clone(),
hint_label,
hint_lines,
}
})
.collect()
}
fn intake_repair_source_reports(
explanations: &[IntakeSourceExplanation],
failures: &[ConfiguredSourceFailure],
) -> Vec<IntakeReportRepairSource> {
let mut seen = BTreeSet::new();
let mut reports = Vec::new();
for explanation in explanations
.iter()
.filter(|explanation| matches!(explanation.decision, IntakeSourceDecision::Skipped))
{
push_intake_repair_source_report(
&mut reports,
&mut seen,
&explanation.name,
&explanation.reason,
);
}
for failure in failures {
push_intake_repair_source_report(&mut reports, &mut seen, &failure.name, &failure.error);
}
reports
}
fn push_intake_repair_source_report(
reports: &mut Vec<IntakeReportRepairSource>,
seen: &mut BTreeSet<(String, String)>,
name: &str,
reason: &str,
) {
let key = (normalized_source_key(name), reason.to_string());
if !seen.insert(key) {
return;
}
let explanation = IntakeSourceExplanation {
name: name.to_string(),
decision: IntakeSourceDecision::Skipped,
reason: reason.to_string(),
};
let Some(hint) = intake_source_hint(&explanation) else {
return;
};
reports.push(IntakeReportRepairSource {
source: display_source_label(name),
reason: reason.to_string(),
commands: hint.lines,
});
}
fn intake_curation_notes(result: &ConfiguredRunResult) -> Vec<String> {
let Some(prior) = &result.prior_curation else {
return Vec::new();
};
if prior.copied {
vec![format!(
"Prior workstream curation reused from run {} ({}) into {}.",
prior.source_run_dir.display(),
prior.source_path.display(),
prior.destination_path.display()
)]
} else {
vec![format!(
"Existing workstream curation reused from {}.",
prior.destination_path.display()
)]
}
}
fn intake_readiness_next_steps(
run_id: &str,
out_dir: &Path,
config_path: &Path,
failures: &[ConfiguredSourceFailure],
first_no_receipt_workstream: Option<&str>,
first_broad_workstream: Option<&str>,
first_manual_context_workstream: Option<&str>,
) -> Vec<String> {
let out_arg = quote_cli_value(&out_dir.display().to_string());
let mut steps = Vec::new();
if let Some(title) = first_manual_context_workstream {
steps.push(journal_add_next_step(title));
}
if let Some(title) = first_no_receipt_workstream {
steps.push(format!(
"shiplog workstreams receipts --out {out_arg} --run {run_id} --workstream {}",
quote_cli_value(title)
));
}
if let Some(title) = first_broad_workstream {
steps.push(format!(
"shiplog workstreams split --out {out_arg} --run {run_id} --from {} --to \"<new workstream>\" --matching \"<pattern>\" --create",
quote_cli_value(title)
));
}
if !failures.is_empty() {
steps.push(format!(
"shiplog doctor --config {}",
quote_cli_value(&config_path.display().to_string())
));
}
steps.push(format!(
"shiplog render --out {out_arg} --run {run_id} --bundle-profile manager"
));
steps
}
#[derive(Debug, Default)]
struct IntakeConfigSetup {
created: bool,
source_explanations: Vec<IntakeSourceExplanation>,
}
fn ensure_intake_config(
config_path: &Path,
requested_sources: &[InitSource],
) -> Result<IntakeConfigSetup> {
if config_path.exists() {
return Ok(IntakeConfigSetup::default());
}
let selected = selected_intake_sources(requested_sources);
let source_explanations = intake_autodetect_explanations(requested_sources, &selected);
let config = render_init_config(&selected);
write_init_file(config_path, &config)?;
if init_source_enabled(&selected, InitSource::Manual) {
let manual_path = config_base_dir(config_path).join(MANUAL_EVENTS_FILENAME);
if !manual_path.exists() {
write_init_file(&manual_path, &render_manual_events_template())?;
}
}
Ok(IntakeConfigSetup {
created: true,
source_explanations,
})
}
fn selected_intake_sources(requested_sources: &[InitSource]) -> Vec<InitSource> {
if !requested_sources.is_empty() {
return dedupe_sources(requested_sources);
}
let mut selected = Vec::new();
if env_var_present("GITHUB_TOKEN") {
selected.push(InitSource::Github);
}
if env_var_present("GITLAB_TOKEN") {
selected.push(InitSource::Gitlab);
}
if Path::new(".git").exists() {
selected.push(InitSource::Git);
}
if Path::new("ledger.events.jsonl").exists() && Path::new("coverage.manifest.json").exists() {
selected.push(InitSource::Json);
}
selected.push(InitSource::Manual);
dedupe_sources(&selected)
}
fn dedupe_sources(sources: &[InitSource]) -> Vec<InitSource> {
let mut selected = Vec::new();
for source in sources {
if !selected.contains(source) {
selected.push(*source);
}
}
selected
}
fn intake_autodetect_explanations(
requested_sources: &[InitSource],
selected: &[InitSource],
) -> Vec<IntakeSourceExplanation> {
if !requested_sources.is_empty() {
return Vec::new();
}
let mut explanations = Vec::new();
if !init_source_enabled(selected, InitSource::Github) {
push_intake_explanation(
&mut explanations,
"github",
IntakeSourceDecision::Skipped,
"GITHUB_TOKEN not found",
);
}
if !init_source_enabled(selected, InitSource::Gitlab) {
push_intake_explanation(
&mut explanations,
"gitlab",
IntakeSourceDecision::Skipped,
"GITLAB_TOKEN not found",
);
}
if !init_source_enabled(selected, InitSource::Jira) {
push_intake_explanation(
&mut explanations,
"jira",
IntakeSourceDecision::Skipped,
"JIRA_TOKEN not found or Jira source config missing",
);
}
if !init_source_enabled(selected, InitSource::Linear) {
push_intake_explanation(
&mut explanations,
"linear",
IntakeSourceDecision::Skipped,
"LINEAR_API_KEY not found or Linear source config missing",
);
}
if !init_source_enabled(selected, InitSource::Git) {
push_intake_explanation(
&mut explanations,
"git",
IntakeSourceDecision::Skipped,
"current directory is not a git repo",
);
}
if !init_source_enabled(selected, InitSource::Json) {
push_intake_explanation(
&mut explanations,
"json",
IntakeSourceDecision::Skipped,
"ledger.events.jsonl and coverage.manifest.json not found",
);
}
explanations
}
fn prepare_intake_sources(
config_path: &Path,
config: &mut ShiplogConfig,
explicit_sources: &[InitSource],
) -> Result<IntakeSourcePlan> {
let base_dir = config_base_dir(config_path);
let mut plan = IntakeSourcePlan::default();
if let Some(source) = config.sources.github.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Github) {
source.enabled = false;
} else if source.enabled {
if optional_config_string(source.user.as_deref()).is_some() && source.me {
source.enabled = false;
push_intake_skip(&mut plan, "github", "configured both user and me");
} else if !env_var_present("GITHUB_TOKEN") {
source.enabled = false;
push_intake_skip(&mut plan, "github", "missing GITHUB_TOKEN");
} else if source.me {
let api_base = optional_config_string(source.api_base.as_deref())
.unwrap_or_else(|| "https://api.github.com".to_string());
match discover_github_user(&api_base, None) {
Ok(user) => {
push_intake_include(
&mut plan,
"github",
format!("GITHUB_TOKEN found; --me resolved as {user}"),
);
source.user = Some(user);
source.me = false;
}
Err(err) => {
source.enabled = false;
push_intake_skip(&mut plan, "github", err.to_string());
}
}
} else if optional_config_string(source.user.as_deref()).is_none() && !source.me {
source.enabled = false;
push_intake_skip(&mut plan, "github", "set sources.github.user or me = true");
} else if let Some(user) = optional_config_string(source.user.as_deref()) {
push_intake_include(
&mut plan,
"github",
format!("GITHUB_TOKEN found; user configured as {user}"),
);
}
}
}
if let Some(source) = config.sources.gitlab.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Gitlab) {
source.enabled = false;
} else if source.enabled {
if optional_config_string(source.user.as_deref()).is_some() && source.me {
source.enabled = false;
push_intake_skip(&mut plan, "gitlab", "configured both user and me");
} else if !env_var_present("GITLAB_TOKEN") {
source.enabled = false;
push_intake_skip(&mut plan, "gitlab", "missing GITLAB_TOKEN");
} else if source.me {
let instance = optional_config_string(source.instance.as_deref())
.unwrap_or_else(|| "gitlab.com".to_string());
match discover_gitlab_user(&instance, None) {
Ok(user) => {
push_intake_include(
&mut plan,
"gitlab",
format!("GITLAB_TOKEN found; --me resolved as {user}"),
);
source.user = Some(user);
source.me = false;
}
Err(err) => {
source.enabled = false;
push_intake_skip(&mut plan, "gitlab", err.to_string());
}
}
} else if optional_config_string(source.user.as_deref()).is_none() && !source.me {
source.enabled = false;
push_intake_skip(&mut plan, "gitlab", "set sources.gitlab.user or me = true");
} else if let Some(user) = optional_config_string(source.user.as_deref()) {
push_intake_include(
&mut plan,
"gitlab",
format!("GITLAB_TOKEN found; user configured as {user}"),
);
}
}
}
if let Some(source) = config.sources.jira.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Jira) {
source.enabled = false;
} else if source.enabled {
let user = optional_config_string(source.user.as_deref());
let instance = optional_config_string(source.instance.as_deref());
if !env_var_present("JIRA_TOKEN") {
source.enabled = false;
push_intake_skip(&mut plan, "jira", "missing JIRA_TOKEN");
} else if user.as_deref().is_none_or(is_unfilled_placeholder) {
source.enabled = false;
push_intake_skip(&mut plan, "jira", "set sources.jira.user");
} else if instance.as_deref().is_none_or(is_unfilled_placeholder) {
source.enabled = false;
push_intake_skip(&mut plan, "jira", "set sources.jira.instance");
} else {
push_intake_include(
&mut plan,
"jira",
"JIRA_TOKEN found; assignee and instance configured",
);
}
}
}
if let Some(source) = config.sources.linear.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Linear) {
source.enabled = false;
} else if source.enabled {
let user_id = optional_config_string(source.user_id.as_deref());
if !env_var_present("LINEAR_API_KEY") {
source.enabled = false;
push_intake_skip(&mut plan, "linear", "missing LINEAR_API_KEY");
} else if user_id.as_deref().is_none_or(is_unfilled_placeholder) {
source.enabled = false;
push_intake_skip(&mut plan, "linear", "set sources.linear.user_id");
} else {
push_intake_include(
&mut plan,
"linear",
"LINEAR_API_KEY found; user id configured",
);
}
}
}
if let Some(source) = config.sources.git.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Git) {
source.enabled = false;
} else if source.enabled {
match source
.repo
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(&base_dir, path))
{
Some(repo) if repo.exists() => {
push_intake_include(&mut plan, "git", format!("repo {} found", repo.display()));
}
Some(repo) => {
source.enabled = false;
push_intake_skip(
&mut plan,
"git",
format!("repo {} not found", repo.display()),
);
}
None => {
source.enabled = false;
push_intake_skip(&mut plan, "git", "set sources.git.repo");
}
}
}
}
if let Some(source) = config.sources.json.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Json) {
source.enabled = false;
} else if source.enabled {
let events = source
.events
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(&base_dir, path));
let coverage = source
.coverage
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(&base_dir, path));
match (events, coverage) {
(Some(events), Some(coverage)) if events.exists() && coverage.exists() => {
push_intake_include(&mut plan, "json", "events and coverage files found");
}
(Some(events), Some(coverage)) => {
source.enabled = false;
push_intake_skip(
&mut plan,
"json",
format!("missing {} or {}", events.display(), coverage.display()),
);
}
_ => {
source.enabled = false;
push_intake_skip(
&mut plan,
"json",
"set sources.json.events and sources.json.coverage",
);
}
}
}
}
if let Some(source) = config.sources.manual.as_mut() {
if !intake_source_in_scope(explicit_sources, InitSource::Manual) {
source.enabled = false;
} else if source.enabled {
if source
.events
.as_ref()
.is_none_or(|path| path.as_os_str().is_empty())
{
source.events = Some(PathBuf::from(MANUAL_EVENTS_FILENAME));
}
let events = source
.events
.as_ref()
.map(|path| resolve_config_path(&base_dir, path))
.expect("manual events path set above");
let existed = events.exists();
if !events.exists() {
write_init_file(&events, &render_manual_events_template())?;
}
if existed {
push_intake_include(&mut plan, "manual", "manual_events.yaml found");
} else {
push_intake_include(&mut plan, "manual", "manual_events.yaml created");
}
}
}
Ok(plan)
}
fn intake_source_in_scope(explicit_sources: &[InitSource], source: InitSource) -> bool {
explicit_sources.is_empty() || explicit_sources.contains(&source)
}
fn push_intake_include(plan: &mut IntakeSourcePlan, name: &str, reason: impl Into<String>) {
push_intake_explanation(
&mut plan.explanations,
name,
IntakeSourceDecision::Included,
reason,
);
}
fn push_intake_skip(plan: &mut IntakeSourcePlan, name: &str, error: impl Into<String>) {
let error = error.into();
plan.failures.push(ConfiguredSourceFailure {
name: name.to_string(),
error: error.clone(),
});
push_intake_explanation(
&mut plan.explanations,
name,
IntakeSourceDecision::Skipped,
error,
);
}
fn push_intake_explanation(
explanations: &mut Vec<IntakeSourceExplanation>,
name: &str,
decision: IntakeSourceDecision,
reason: impl Into<String>,
) {
explanations.push(IntakeSourceExplanation {
name: name.to_string(),
decision,
reason: reason.into(),
});
}
fn print_intake_explanations(explanations: &[IntakeSourceExplanation]) {
println!("Source decisions:");
if explanations.is_empty() {
println!("- None");
return;
}
for explanation in explanations {
let decision = match explanation.decision {
IntakeSourceDecision::Included => "included",
IntakeSourceDecision::Skipped => "skipped",
};
println!(
"- {}: {}, {}",
display_source_label(&explanation.name),
decision,
explanation.reason
);
if let Some(hint) = intake_source_hint(explanation) {
println!(" {}:", hint.label);
for line in hint.lines {
println!(" {line}");
}
}
}
}
fn intake_source_hint(explanation: &IntakeSourceExplanation) -> Option<IntakeSourceHint> {
let source = normalized_source_key(&explanation.name);
let reason = explanation.reason.to_ascii_lowercase();
if matches!(explanation.decision, IntakeSourceDecision::Included) {
return match source.as_str() {
"manual" => Some(IntakeSourceHint {
label: "Hint",
lines: vec![
"Use `shiplog journal add` to capture missing context without editing YAML."
.to_string(),
],
}),
_ => None,
};
}
let mut lines = match source.as_str() {
"github" => github_repair_hint(&reason),
"gitlab" => gitlab_repair_hint(&reason),
"jira" => jira_repair_hint(&reason),
"linear" => linear_repair_hint(&reason),
"git" => git_repair_hint(&reason),
"json" => vec![
"Set sources.json.events and sources.json.coverage to existing fixture files."
.to_string(),
"Or disable [sources.json] if JSON import is not part of this intake.".to_string(),
],
"manual" => vec![
"Run `shiplog journal add --date <YYYY-MM-DD> --title <title> --workstream <name>`."
.to_string(),
format!("Or create {MANUAL_EVENTS_FILENAME} with `shiplog init --force`."),
],
_ => vec!["Run `shiplog doctor` to inspect source configuration.".to_string()],
};
if !lines
.iter()
.any(|line| line.contains("shiplog intake --last-6-months --explain"))
{
lines.push("Retry with `shiplog intake --last-6-months --explain`.".to_string());
}
Some(IntakeSourceHint {
label: "Fix",
lines,
})
}
fn github_repair_hint(reason: &str) -> Vec<String> {
if reason.contains("token") {
return vec![
"export GITHUB_TOKEN=...".to_string(),
"Use sources.github.me = true or set sources.github.user in shiplog.toml.".to_string(),
];
}
if reason.contains("both user and me") {
return vec!["Keep either sources.github.user or sources.github.me, not both.".to_string()];
}
if reason.contains("user") || reason.contains("me = true") {
return vec![
"Set sources.github.me = true to use the authenticated user.".to_string(),
"Or set sources.github.user to an explicit GitHub login.".to_string(),
];
}
vec![
"Set sources.github.user explicitly if identity discovery cannot resolve --me.".to_string(),
]
}
fn gitlab_repair_hint(reason: &str) -> Vec<String> {
if reason.contains("token") {
return vec![
"export GITLAB_TOKEN=...".to_string(),
"Use sources.gitlab.me = true or set sources.gitlab.user in shiplog.toml.".to_string(),
];
}
if reason.contains("state") || reason.contains("invalid mr state") {
return vec![
"Set sources.gitlab.state to opened, merged, closed, or all.".to_string(),
"Check sources.gitlab.instance for self-hosted GitLab hosts.".to_string(),
];
}
if reason.contains("both user and me") {
return vec!["Keep either sources.gitlab.user or sources.gitlab.me, not both.".to_string()];
}
if reason.contains("user") || reason.contains("me = true") {
return vec![
"Set sources.gitlab.me = true to use the authenticated user.".to_string(),
"Or set sources.gitlab.user to an explicit GitLab username.".to_string(),
];
}
vec![
"Set sources.gitlab.user explicitly if identity discovery cannot resolve --me.".to_string(),
]
}
fn jira_repair_hint(reason: &str) -> Vec<String> {
if reason.contains("token") {
return vec![
"export JIRA_TOKEN=...".to_string(),
"Run `shiplog identify jira --auth-user <email>` to confirm the authenticated account."
.to_string(),
];
}
if reason.contains("status") || reason.contains("invalid issue status") {
return vec![
"Set sources.jira.status to open, in_progress, done, closed, or all.".to_string(),
"Jira --user remains the assignee JQL value; use auth_user only for Basic Auth."
.to_string(),
];
}
if reason.contains("instance") {
return vec![
"Set sources.jira.instance to your Atlassian host, such as company.atlassian.net."
.to_string(),
];
}
vec![
"Set sources.jira.user to the assignee JQL value for this review packet.".to_string(),
"Use --auth-user only when Basic Auth uses a different email.".to_string(),
]
}
fn linear_repair_hint(reason: &str) -> Vec<String> {
if reason.contains("token") || reason.contains("api_key") {
return vec![
"export LINEAR_API_KEY=...".to_string(),
"Run `shiplog identify linear` to confirm candidate user IDs.".to_string(),
];
}
if reason.contains("status") || reason.contains("invalid issue status") {
return vec![
"Set sources.linear.status to backlog, todo, in_progress, done, cancelled, or all."
.to_string(),
"Check sources.linear.project only after the status filter is valid.".to_string(),
];
}
if reason.contains("project") {
return vec![
"Check sources.linear.project against the Linear project key or name.".to_string(),
"Omit sources.linear.project to collect all configured user issues.".to_string(),
];
}
vec![
"Run `shiplog identify linear` to find the user id.".to_string(),
"Then set sources.linear.user_id in shiplog.toml.".to_string(),
]
}
fn git_repair_hint(reason: &str) -> Vec<String> {
if reason.contains("not a git repo") {
return vec![
"Run intake from a git repository root.".to_string(),
"Or set sources.git.repo to a local repository path in shiplog.toml.".to_string(),
];
}
vec![
"Set sources.git.repo to an existing local repository path.".to_string(),
"Or run intake from the repository root.".to_string(),
]
}
fn is_unfilled_placeholder(value: &str) -> bool {
let value = value.trim().to_ascii_lowercase();
value.is_empty() || value.starts_with("your-") || value == "company.atlassian.net"
}
fn selected_init_sources(sources: &[InitSource]) -> Vec<InitSource> {
if sources.is_empty() {
return vec![InitSource::Github, InitSource::Manual];
}
let mut selected = Vec::new();
for source in sources {
if !selected.contains(source) {
selected.push(*source);
}
}
selected
}
fn init_source_enabled(selected: &[InitSource], source: InitSource) -> bool {
selected.contains(&source)
}
fn init_env_vars(selected: &[InitSource]) -> Vec<&'static str> {
let mut vars = Vec::new();
if init_source_enabled(selected, InitSource::Github) {
vars.push("GITHUB_TOKEN");
}
if init_source_enabled(selected, InitSource::Gitlab) {
vars.push("GITLAB_TOKEN");
}
if init_source_enabled(selected, InitSource::Jira) {
vars.push("JIRA_TOKEN");
}
if init_source_enabled(selected, InitSource::Linear) {
vars.push("LINEAR_API_KEY");
}
vars
}
fn init_next_command(_selected: &[InitSource]) -> &'static str {
"shiplog collect multi --last-6-months"
}
fn ensure_init_files_available(paths: &[&Path], force: bool) -> Result<()> {
if force {
return Ok(());
}
for path in paths {
if path.exists() {
anyhow::bail!(
"{} already exists; use --force to overwrite",
path.display()
);
}
}
Ok(())
}
fn write_init_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(path, contents).with_context(|| format!("write {}", path.display()))
}
fn render_init_config(selected: &[InitSource]) -> String {
let github = init_source_enabled(selected, InitSource::Github);
let gitlab = init_source_enabled(selected, InitSource::Gitlab);
let jira = init_source_enabled(selected, InitSource::Jira);
let linear = init_source_enabled(selected, InitSource::Linear);
let git = init_source_enabled(selected, InitSource::Git);
let json = init_source_enabled(selected, InitSource::Json);
let manual = init_source_enabled(selected, InitSource::Manual);
format!(
r#"# shiplog local configuration.
# Tokens stay in environment variables:
# GITHUB_TOKEN, GITLAB_TOKEN, JIRA_TOKEN, LINEAR_API_KEY, SHIPLOG_REDACT_KEY.
[shiplog]
config_version = 1
[defaults]
out = "./out"
window = "last-6-months"
profile = "internal"
include_reviews = true
[periods."review-cycle"]
preset = "last-6-months"
[user]
label = "Your Name"
[sources.github]
enabled = {github}
user = ""
me = {github}
mode = "merged"
include_reviews = true
[sources.gitlab]
enabled = {gitlab}
user = ""
me = {gitlab}
instance = "gitlab.com"
state = "merged"
include_reviews = true
[sources.jira]
enabled = {jira}
user = "your-jira-account-id-or-email"
auth_user_env = "JIRA_AUTH_USER"
instance = "company.atlassian.net"
status = "done"
[sources.linear]
enabled = {linear}
user_id = "your-linear-user-id"
status = "done"
project = ""
[sources.git]
enabled = {git}
repo = "."
author = ""
include_merges = false
[sources.json]
enabled = {json}
events = "./ledger.events.jsonl"
coverage = "./coverage.manifest.json"
[sources.manual]
enabled = {manual}
events = "./manual_events.yaml"
user = "Your Name"
[redaction]
key_env = "SHIPLOG_REDACT_KEY"
"#
)
}
fn render_manual_events_template() -> String {
let generated_at = Utc::now().to_rfc3339();
format!(
r#"# Manual evidence entries for shiplog.
# Add events for work that is not visible in code-hosting or issue systems.
# Keep entries factual and receipt-oriented.
#
# Example:
# events:
# - id: manual-2026-01-15-incident-follow-up
# type: Note
# date: "2026-01-15"
# title: "Incident follow-up"
# description: "Summarize the work, not the performance narrative."
# workstream: "platform reliability"
# tags: ["review-cycle"]
# receipts:
# - label: "runbook"
# url: "https://example.com/runbook"
# impact: "Reduced operational risk."
version: 1
generated_at: "{generated_at}"
events: []
"#
)
}
fn run_journal_add(args: JournalAddArgs) -> Result<()> {
let date = resolve_journal_date(args.date, args.start, args.end)?;
let id = match args.id {
Some(id) => required_text_arg("--id", &id)?,
None => generated_journal_id(&date, &args.title),
};
validate_journal_id(&id)?;
let mut file = if args.events.exists() {
read_manual_events(&args.events)?
} else {
create_empty_file()
};
if file.version != 1 {
anyhow::bail!(
"unsupported manual events version {}; expected 1",
file.version
);
}
if file.events.iter().any(|entry| entry.id == id) {
anyhow::bail!(
"manual event id {id:?} already exists in {}; use --id for a distinct entry",
args.events.display()
);
}
let entry = ManualEventEntry {
id,
event_type: args.event_type.into(),
date,
title: required_text_arg("--title", &args.title)?,
description: optional_text_arg(args.description),
workstream: optional_text_arg(args.workstream),
tags: normalize_journal_tags(args.tags)?,
receipts: parse_journal_receipts(&args.receipts)?,
impact: optional_text_arg(args.impact),
};
if args.dry_run {
print_journal_entry("Would add manual event", &args.events, &entry);
return Ok(());
}
if let Some(parent) = args
.events
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.with_context(|| format!("create manual events directory {}", parent.display()))?;
}
file.events.push(entry.clone());
write_manual_events(&args.events, &file)?;
print_journal_entry("Added manual event", &args.events, &entry);
println!("Next:");
println!(" shiplog collect multi --last-6-months");
Ok(())
}
fn run_journal_list(args: JournalListArgs) -> Result<()> {
if !args.events.exists() {
anyhow::bail!(
"No manual events file found at {:?}. Run `shiplog journal add --events {:?}` to create one.",
args.events,
args.events
);
}
let file = read_manual_events(&args.events)?;
if file.version != 1 {
anyhow::bail!(
"unsupported manual events version {}; expected 1",
file.version
);
}
let workstream_filter = args
.workstream
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let tag_filters = normalize_journal_tags(args.tags)?;
let mut entries: Vec<&ManualEventEntry> = file
.events
.iter()
.filter(|entry| journal_entry_matches_filters(entry, workstream_filter, &tag_filters))
.collect();
entries.sort_by(|left, right| {
journal_date_start(&left.date)
.cmp(&journal_date_start(&right.date))
.then_with(|| left.id.cmp(&right.id))
});
println!("Manual events: {}", args.events.display());
println!("Count: {}", entries.len());
if entries.is_empty() {
println!("No manual events matched.");
return Ok(());
}
for entry in entries {
println!(
"- {} | {} | {} | {} | {}",
entry.id,
journal_date_label(&entry.date),
entry.event_type,
entry.workstream.as_deref().unwrap_or("Unassigned"),
entry.title
);
if !entry.tags.is_empty() {
println!(" tags: {}", entry.tags.join(", "));
}
println!(" receipts: {}", entry.receipts.len());
}
Ok(())
}
fn run_journal_edit(args: JournalEditArgs) -> Result<()> {
let id = required_text_arg("--id", &args.id)?;
validate_journal_id(&id)?;
let events_path = args.events.clone();
if !events_path.exists() {
anyhow::bail!("No manual events file found at {:?}", events_path);
}
let replacement_date = resolve_optional_journal_date(args.date, args.start, args.end)?;
if !journal_edit_has_changes(&args, replacement_date.as_ref()) {
anyhow::bail!(
"journal edit requires at least one field to update: --title, --type, --date, --start/--end, --workstream, --tag, --receipt, --description, or --impact"
);
}
let mut file = read_manual_events(&events_path)?;
if file.version != 1 {
anyhow::bail!(
"unsupported manual events version {}; expected 1",
file.version
);
}
let matching: Vec<usize> = file
.events
.iter()
.enumerate()
.filter_map(|(index, entry)| (entry.id == id).then_some(index))
.collect();
let [entry_index] = matching.as_slice() else {
if matching.is_empty() {
anyhow::bail!(
"manual event id {id:?} was not found in {}",
events_path.display()
);
}
anyhow::bail!(
"manual event id {id:?} appears more than once in {}; edit would be ambiguous",
events_path.display()
);
};
let dry_run = args.dry_run;
let updated = apply_journal_edit(file.events[*entry_index].clone(), args, replacement_date)?;
if dry_run {
print_journal_entry("Would edit manual event", &events_path, &updated);
return Ok(());
}
file.events[*entry_index] = updated.clone();
write_manual_events(&events_path, &file)?;
print_journal_entry("Edited manual event", &events_path, &updated);
println!("Next:");
println!(" shiplog collect multi --last-6-months");
Ok(())
}
fn apply_journal_edit(
mut entry: ManualEventEntry,
args: JournalEditArgs,
replacement_date: Option<ManualDate>,
) -> Result<ManualEventEntry> {
if let Some(event_type) = args.event_type {
entry.event_type = event_type.into();
}
if let Some(date) = replacement_date {
entry.date = date;
}
if let Some(title) = args.title {
entry.title = required_text_arg("--title", &title)?;
}
if args.description.is_some() {
entry.description = optional_text_arg(args.description);
}
if args.workstream.is_some() {
entry.workstream = optional_text_arg(args.workstream);
}
if !args.tags.is_empty() {
entry.tags = normalize_journal_tags(args.tags)?;
}
if !args.receipts.is_empty() {
entry.receipts = parse_journal_receipts(&args.receipts)?;
}
if args.impact.is_some() {
entry.impact = optional_text_arg(args.impact);
}
Ok(entry)
}
fn journal_edit_has_changes(args: &JournalEditArgs, replacement_date: Option<&ManualDate>) -> bool {
args.event_type.is_some()
|| replacement_date.is_some()
|| args.title.is_some()
|| args.description.is_some()
|| args.workstream.is_some()
|| !args.tags.is_empty()
|| !args.receipts.is_empty()
|| args.impact.is_some()
}
fn journal_entry_matches_filters(
entry: &ManualEventEntry,
workstream: Option<&str>,
tags: &[String],
) -> bool {
if let Some(workstream) = workstream {
let Some(entry_workstream) = entry.workstream.as_deref() else {
return false;
};
if !entry_workstream.eq_ignore_ascii_case(workstream) {
return false;
}
}
tags.iter()
.all(|tag| entry.tags.iter().any(|entry_tag| entry_tag == tag))
}
fn resolve_optional_journal_date(
date: Option<NaiveDate>,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
) -> Result<Option<ManualDate>> {
match (date, start, end) {
(None, None, None) => Ok(None),
(Some(date), None, None) => Ok(Some(ManualDate::Single(date))),
(None, Some(start), Some(end)) if start <= end => {
Ok(Some(ManualDate::Range { start, end }))
}
(None, Some(start), Some(end)) => {
anyhow::bail!("journal date range must satisfy --start {start} <= --end {end}")
}
(None, Some(_), None) | (None, None, Some(_)) => {
anyhow::bail!("journal edit date range requires both --start and --end")
}
_ => anyhow::bail!("use either --date or --start/--end, not both"),
}
}
fn resolve_journal_date(
date: Option<NaiveDate>,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
) -> Result<ManualDate> {
match (date, start, end) {
(Some(date), None, None) => Ok(ManualDate::Single(date)),
(None, Some(start), Some(end)) if start <= end => Ok(ManualDate::Range { start, end }),
(None, Some(start), Some(end)) => {
anyhow::bail!("journal date range must satisfy --start {start} <= --end {end}")
}
(None, None, None) => anyhow::bail!("journal add requires --date or --start/--end"),
_ => anyhow::bail!("use either --date or --start/--end, not both"),
}
}
fn generated_journal_id(date: &ManualDate, title: &str) -> String {
let date_label = match date {
ManualDate::Single(date) => date.to_string(),
ManualDate::Range { start, end } => format!("{start}-to-{end}"),
};
let slug = slugify_journal_title(title);
format!("manual-{date_label}-{slug}")
}
fn slugify_journal_title(title: &str) -> String {
let mut slug = String::new();
let mut pending_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
if pending_dash && !slug.is_empty() {
slug.push('-');
}
slug.push(ch);
pending_dash = false;
} else {
pending_dash = true;
}
}
if slug.is_empty() {
"event".to_string()
} else {
slug
}
}
fn validate_journal_id(id: &str) -> Result<()> {
if id.trim().is_empty() {
anyhow::bail!("manual event id cannot be blank");
}
if id.chars().any(char::is_whitespace) {
anyhow::bail!("manual event id cannot contain whitespace: {id:?}");
}
Ok(())
}
fn required_text_arg(name: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("{name} cannot be blank");
}
Ok(trimmed.to_string())
}
fn optional_text_arg(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_journal_tags(tags: Vec<String>) -> Result<Vec<String>> {
let mut normalized = Vec::new();
for tag in tags {
let tag = tag.trim();
if tag.is_empty() {
anyhow::bail!("journal tag cannot be blank");
}
if !normalized.iter().any(|existing| existing == tag) {
normalized.push(tag.to_string());
}
}
Ok(normalized)
}
fn parse_journal_receipts(values: &[String]) -> Result<Vec<Link>> {
let mut receipts = Vec::new();
for value in values {
let (label, url) = match value.split_once('=') {
Some((label, url)) => (label.trim(), url.trim()),
None => ("receipt", value.trim()),
};
if label.is_empty() {
anyhow::bail!("journal receipt label cannot be blank in {value:?}");
}
if url.is_empty() {
anyhow::bail!("journal receipt URL cannot be blank in {value:?}");
}
receipts.push(Link {
label: label.to_string(),
url: url.to_string(),
});
}
Ok(receipts)
}
fn print_journal_entry(label: &str, path: &Path, entry: &ManualEventEntry) {
println!("{label}: {}", entry.id);
println!("File: {}", path.display());
println!("Type: {}", entry.event_type);
println!("Date: {}", journal_date_label(&entry.date));
println!("Title: {}", entry.title);
if let Some(workstream) = &entry.workstream {
println!("Workstream: {workstream}");
}
if !entry.tags.is_empty() {
println!("Tags: {}", entry.tags.join(", "));
}
if !entry.receipts.is_empty() {
println!("Receipts: {}", entry.receipts.len());
}
}
fn journal_date_start(date: &ManualDate) -> NaiveDate {
match date {
ManualDate::Single(date) => *date,
ManualDate::Range { start, .. } => *start,
}
}
fn journal_date_label(date: &ManualDate) -> String {
match date {
ManualDate::Single(date) => date.to_string(),
ManualDate::Range { start, end } => format!("{start}..{end}"),
}
}
fn load_shiplog_config(config_path: &Path) -> Result<ShiplogConfig> {
let text = std::fs::read_to_string(config_path)
.with_context(|| format!("read {}", config_path.display()))?;
toml::from_str(&text).with_context(|| format!("parse {}", config_path.display()))
}
#[derive(Default)]
struct DoctorReport {
errors: usize,
}
impl DoctorReport {
fn ok(&self, label: &str, detail: impl AsRef<str>) {
let detail = detail.as_ref();
if detail.is_empty() {
println!("{label}: ok");
} else {
println!("{label}: ok, {detail}");
}
}
fn disabled(&self, label: &str) {
println!("{label}: disabled");
}
fn error(&mut self, label: &str, detail: impl AsRef<str>) {
self.errors += 1;
println!("{label}: error, {}", detail.as_ref());
}
}
fn run_doctor(config_path: &Path, sources: &[InitSource]) -> Result<()> {
let mut report = DoctorReport::default();
if !config_path.exists() {
report.error(
"Config",
format!(
"{} not found; run `shiplog init` first",
config_path.display()
),
);
anyhow::bail!("doctor found {} issue(s)", report.errors);
}
let config = match load_shiplog_config(config_path) {
Ok(config) => {
report.ok("Config", config_path.display().to_string());
config
}
Err(err) => {
report.error("Config", err.to_string());
anyhow::bail!("doctor found {} issue(s)", report.errors);
}
};
let base_dir = config_base_dir(config_path);
doctor_config_version(&mut report, &config);
if report.errors > 0 {
anyhow::bail!("doctor found {} issue(s)", report.errors);
}
doctor_defaults(&mut report, &config, &base_dir);
doctor_sources(&mut report, &config, &base_dir, sources);
if report.errors > 0 {
anyhow::bail!("doctor found {} issue(s)", report.errors);
}
Ok(())
}
fn doctor_config_version(report: &mut DoctorReport, config: &ShiplogConfig) {
match config_version_state(config) {
Ok(version) => report.ok("Config version", version.label()),
Err(err) => report.error("Config version", err.to_string()),
}
}
#[derive(Debug)]
struct ConfigIssue {
label: &'static str,
detail: String,
}
#[derive(Debug, Clone, Copy)]
struct ConfigVersionState {
version: i64,
explicit: bool,
}
enum ConfigMigration {
Current(ConfigVersionState),
AddVersion { text: String },
}
fn config_issue(label: &'static str, detail: impl Into<String>) -> ConfigIssue {
ConfigIssue {
label,
detail: detail.into(),
}
}
fn run_config_validate(config_path: &Path) -> Result<()> {
let config = load_config_for_command(config_path)?;
let base_dir = config_base_dir(config_path);
let issues = validate_shiplog_config(&config, &base_dir);
println!("Config: ok, {}", config_path.display());
if issues.is_empty() {
print_config_validation_summary(&config, &base_dir)?;
println!("Config valid");
return Ok(());
}
for issue in &issues {
println!("{}: error, {}", issue.label, issue.detail);
}
anyhow::bail!("config validate found {} issue(s)", issues.len())
}
fn run_config_explain(config_path: &Path) -> Result<()> {
let config = load_config_for_command(config_path)?;
let base_dir = config_base_dir(config_path);
let issues = validate_shiplog_config(&config, &base_dir);
if !issues.is_empty() {
println!("Config: ok, {}", config_path.display());
for issue in &issues {
println!("{}: error, {}", issue.label, issue.detail);
}
anyhow::bail!("config explain requires a valid config")
}
print_config_explanation(config_path, &config, &base_dir)
}
fn run_config_migrate(config_path: &Path, dry_run: bool) -> Result<()> {
let text = read_config_for_command(config_path)?;
let migration = plan_config_migration(config_path, &text)?;
println!("Config: ok, {}", config_path.display());
match migration {
ConfigMigration::Current(version) => {
println!("Migration: already current ({})", version.label());
Ok(())
}
ConfigMigration::AddVersion { text } => {
if dry_run {
println!(
"Migration: would add [shiplog] config_version = {CURRENT_CONFIG_VERSION}"
);
} else {
std::fs::write(config_path, text)
.with_context(|| format!("write {}", config_path.display()))?;
println!("Migration: added [shiplog] config_version = {CURRENT_CONFIG_VERSION}");
println!("Config migrated");
}
Ok(())
}
}
}
fn load_config_for_command(config_path: &Path) -> Result<ShiplogConfig> {
if !config_path.exists() {
anyhow::bail!(
"{} not found; run `shiplog init` first",
config_path.display()
);
}
load_shiplog_config(config_path)
}
fn read_config_for_command(config_path: &Path) -> Result<String> {
if !config_path.exists() {
anyhow::bail!(
"{} not found; run `shiplog init` first",
config_path.display()
);
}
std::fs::read_to_string(config_path).with_context(|| format!("read {}", config_path.display()))
}
fn validate_shiplog_config(config: &ShiplogConfig, base_dir: &Path) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if let Err(err) = config_version_state(config) {
issues.push(config_issue("Version", err.to_string()));
}
if let Err(err) = resolve_multi_window(ConfigWindowArgs::default(), config) {
issues.push(config_issue("Window", err.to_string()));
}
validate_config_periods(config, &mut issues);
if let Err(err) = doctor_config_profile(config.defaults.profile.as_deref()) {
issues.push(config_issue("Profile", err.to_string()));
}
if let Err(err) = validate_config_output_path(&config_default_out(config, base_dir)) {
issues.push(config_issue("Output", err.to_string()));
}
if config_enabled_source_names(config).is_empty() {
issues.push(config_issue(
"Sources",
"enable at least one [sources.<name>] section",
));
}
validate_config_github(config, &mut issues);
validate_config_gitlab(config, &mut issues);
validate_config_jira(config, &mut issues);
validate_config_linear(config, &mut issues);
validate_config_git(config, base_dir, &mut issues);
validate_config_json(config, base_dir, &mut issues);
validate_config_manual(config, base_dir, &mut issues);
issues
}
fn validate_config_periods(config: &ShiplogConfig, issues: &mut Vec<ConfigIssue>) {
for name in config.periods.keys() {
if let Err(err) = resolve_config_period(config, name) {
issues.push(config_issue("Period", format!("{name}: {err}")));
}
}
}
impl ConfigVersionState {
fn label(self) -> String {
if self.explicit {
self.version.to_string()
} else {
format!("{} (implicit)", self.version)
}
}
}
fn config_version_state(config: &ShiplogConfig) -> Result<ConfigVersionState> {
match config.shiplog.config_version {
Some(CURRENT_CONFIG_VERSION) => Ok(ConfigVersionState {
version: CURRENT_CONFIG_VERSION,
explicit: true,
}),
Some(version) => anyhow::bail!(
"unsupported config_version {version}; expected {CURRENT_CONFIG_VERSION}; run `shiplog config migrate` or update shiplog"
),
None => Ok(ConfigVersionState {
version: CURRENT_CONFIG_VERSION,
explicit: false,
}),
}
}
fn ensure_supported_config_version(config: &ShiplogConfig) -> Result<()> {
config_version_state(config).map(|_| ())
}
fn plan_config_migration(config_path: &Path, text: &str) -> Result<ConfigMigration> {
let config = load_shiplog_config(config_path)?;
let version = config_version_state(&config)?;
if version.explicit {
return Ok(ConfigMigration::Current(version));
}
Ok(ConfigMigration::AddVersion {
text: add_config_version_to_text(text)?,
})
}
fn add_config_version_to_text(text: &str) -> Result<String> {
let has_shiplog_header = text.lines().any(is_shiplog_table_header);
if !has_shiplog_header {
let parsed: toml::Table = toml::from_str(text).context("parse shiplog.toml")?;
if parsed.contains_key("shiplog") {
anyhow::bail!(
"found a shiplog table but could not locate a [shiplog] header; add config_version manually"
);
}
}
if !has_shiplog_header {
return Ok(format!(
"[shiplog]\nconfig_version = {CURRENT_CONFIG_VERSION}\n\n{text}"
));
}
let mut migrated = String::with_capacity(text.len() + 32);
let mut inserted = false;
for line in text.split_inclusive('\n') {
migrated.push_str(line);
if !inserted && is_shiplog_table_header(line) {
migrated.push_str(&format!("config_version = {CURRENT_CONFIG_VERSION}\n"));
inserted = true;
}
}
if !inserted && is_shiplog_table_header(text) {
migrated.push_str(&format!("\nconfig_version = {CURRENT_CONFIG_VERSION}\n"));
inserted = true;
}
if !inserted {
anyhow::bail!(
"found a shiplog table but could not locate a [shiplog] header; add config_version manually"
);
}
Ok(migrated)
}
fn is_shiplog_table_header(line: &str) -> bool {
let before_comment = line.split('#').next().unwrap_or("").trim();
before_comment == "[shiplog]"
}
fn validate_config_output_path(out: &Path) -> Result<()> {
if out.exists() {
if !out.is_dir() {
anyhow::bail!("{} exists but is not a directory", out.display());
}
return Ok(());
}
let parent = out
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
if !parent.exists() {
anyhow::bail!(
"{} does not exist; create it or choose a different defaults.out",
parent.display()
);
}
if !parent.is_dir() {
anyhow::bail!("{} exists but is not a directory", parent.display());
}
Ok(())
}
fn validate_config_github(config: &ShiplogConfig, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config
.sources
.github
.as_ref()
.filter(|source| source.enabled)
else {
return;
};
validate_config_user_or_me(
"GitHub",
"sources.github.user",
source.user.as_deref(),
source.me,
issues,
);
let mode = optional_config_string(source.mode.as_deref()).unwrap_or_else(|| "merged".into());
if !matches!(mode.as_str(), "merged" | "created") {
issues.push(config_issue(
"GitHub",
format!("sources.github.mode must be merged or created, got {mode:?}"),
));
}
}
fn validate_config_gitlab(config: &ShiplogConfig, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config
.sources
.gitlab
.as_ref()
.filter(|source| source.enabled)
else {
return;
};
validate_config_user_or_me(
"GitLab",
"sources.gitlab.user",
source.user.as_deref(),
source.me,
issues,
);
let instance =
optional_config_string(source.instance.as_deref()).unwrap_or_else(|| "gitlab.com".into());
if let Err(err) = gitlab_api_base(&instance) {
issues.push(config_issue("GitLab", err.to_string()));
}
if let Some(state) = non_empty_string(source.state.as_deref())
&& let Err(err) = state.parse::<MrState>()
{
issues.push(config_issue(
"GitLab",
format!("parse state {state:?}: {err}"),
));
}
}
fn validate_config_jira(config: &ShiplogConfig, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config.sources.jira.as_ref().filter(|source| source.enabled) else {
return;
};
if let Err(err) = required_config_string("jira", "user", source.user.as_deref()) {
issues.push(config_issue("Jira", err.to_string()));
}
if let Err(err) = required_config_string("jira", "instance", source.instance.as_deref()) {
issues.push(config_issue("Jira", err.to_string()));
}
let status = source.status.as_deref().unwrap_or("done");
if let Err(err) = status.parse::<IssueStatus>() {
issues.push(config_issue(
"Jira",
format!("parse status {status:?}: {err}"),
));
}
}
fn validate_config_linear(config: &ShiplogConfig, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config
.sources
.linear
.as_ref()
.filter(|source| source.enabled)
else {
return;
};
if let Err(err) = required_config_string("linear", "user_id", source.user_id.as_deref()) {
issues.push(config_issue("Linear", err.to_string()));
}
let status = source.status.as_deref().unwrap_or("done");
if let Err(err) = status.parse::<LinearIssueStatus>() {
issues.push(config_issue(
"Linear",
format!("parse status {status:?}: {err}"),
));
}
}
fn validate_config_git(config: &ShiplogConfig, base_dir: &Path, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config.sources.git.as_ref().filter(|source| source.enabled) else {
return;
};
match required_config_path(base_dir, "git", "repo", source.repo.as_ref()) {
Ok(repo) if repo.is_dir() => {}
Ok(repo) => issues.push(config_issue(
"Git",
format!("{} is not a directory", repo.display()),
)),
Err(err) => issues.push(config_issue("Git", err.to_string())),
}
}
fn validate_config_json(config: &ShiplogConfig, base_dir: &Path, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config.sources.json.as_ref().filter(|source| source.enabled) else {
return;
};
match required_config_path(base_dir, "json", "events", source.events.as_ref()) {
Ok(events) if events.exists() => {}
Ok(events) => issues.push(config_issue(
"JSON",
format!("{} not found", events.display()),
)),
Err(err) => issues.push(config_issue("JSON", err.to_string())),
}
match required_config_path(base_dir, "json", "coverage", source.coverage.as_ref()) {
Ok(coverage) if coverage.exists() => {}
Ok(coverage) => issues.push(config_issue(
"JSON",
format!("{} not found", coverage.display()),
)),
Err(err) => issues.push(config_issue("JSON", err.to_string())),
}
}
fn validate_config_manual(config: &ShiplogConfig, base_dir: &Path, issues: &mut Vec<ConfigIssue>) {
let Some(source) = config
.sources
.manual
.as_ref()
.filter(|source| source.enabled)
else {
return;
};
match required_config_path(base_dir, "manual", "events", source.events.as_ref()) {
Ok(events) if events.exists() => {}
Ok(events) => issues.push(config_issue(
"Manual",
format!("{} not found", events.display()),
)),
Err(err) => issues.push(config_issue("Manual", err.to_string())),
}
}
fn validate_config_user_or_me(
label: &'static str,
field: &str,
user: Option<&str>,
me: bool,
issues: &mut Vec<ConfigIssue>,
) {
match (optional_config_string(user), me) {
(Some(_), true) => issues.push(config_issue(
label,
format!("use either {field} or me = true, not both"),
)),
(None, false) => issues.push(config_issue(label, format!("set {field} or me = true"))),
_ => {}
}
}
fn print_config_validation_summary(config: &ShiplogConfig, base_dir: &Path) -> Result<()> {
let version = config_version_state(config)?;
let window = resolve_multi_window(ConfigWindowArgs::default(), config)?;
let out = config_default_out(config, base_dir);
let profile = doctor_config_profile(config.defaults.profile.as_deref())?;
println!("Version: ok, {}", version.label());
println!("Window: ok, {}", window.window_label());
println!("Output: ok, {}", out.display());
println!("Profile: ok, {}", profile.as_str());
println!(
"Sources: ok, {}",
config_enabled_source_names(config).join(", ")
);
Ok(())
}
fn print_config_explanation(
config_path: &Path,
config: &ShiplogConfig,
base_dir: &Path,
) -> Result<()> {
let version = config_version_state(config)?;
let window = resolve_multi_window(ConfigWindowArgs::default(), config)?;
let out = config_default_out(config, base_dir);
let profile = doctor_config_profile(config.defaults.profile.as_deref())?;
let window_setting = non_empty_string(config.defaults.window.as_deref())
.unwrap_or_else(|| "last-6-months (default)".to_string());
let out_setting = config
.defaults
.out
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| path.display().to_string())
.unwrap_or_else(|| "./out (default)".to_string());
let include_reviews = config.defaults.include_reviews.unwrap_or(false);
println!("Config: {}", config_path.display());
println!("Config metadata:");
println!("- config_version: {}", version.label());
println!("Resolved defaults:");
println!(
"- window: {} -> {}..{}",
window_setting, window.since, window.until
);
println!("- out: {} -> {}", out_setting, out.display());
println!("- profile: {}", profile.as_str());
println!("- include_reviews: {}", include_reviews);
println!(
"- user.label: {}",
optional_config_string(config.user.label.as_deref()).unwrap_or_else(|| "-".to_string())
);
println!("- redaction.key_env: {}", config_redaction_key_env(config));
println!("Configured periods:");
if config.periods.is_empty() {
println!("- none");
} else {
for name in config.periods.keys() {
let window = resolve_config_period(config, name)?;
println!("- {name}: {}", window.window_label());
}
}
println!("Enabled sources:");
for line in config_enabled_source_explanations(config, base_dir, &out) {
println!("- {line}");
}
Ok(())
}
fn config_default_out(config: &ShiplogConfig, base_dir: &Path) -> PathBuf {
config
.defaults
.out
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(base_dir, path))
.unwrap_or_else(|| resolve_config_path(base_dir, Path::new("./out")))
}
fn config_redaction_key_env(config: &ShiplogConfig) -> String {
optional_config_string(config.redaction.key_env.as_deref())
.unwrap_or_else(|| "SHIPLOG_REDACT_KEY".to_string())
}
fn config_enabled_source_names(config: &ShiplogConfig) -> Vec<&'static str> {
let mut names = Vec::new();
if config
.sources
.github
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("github");
}
if config
.sources
.gitlab
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("gitlab");
}
if config
.sources
.jira
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("jira");
}
if config
.sources
.linear
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("linear");
}
if config
.sources
.git
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("git");
}
if config
.sources
.json
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("json");
}
if config
.sources
.manual
.as_ref()
.is_some_and(|source| source.enabled)
{
names.push("manual");
}
names
}
fn config_enabled_source_explanations(
config: &ShiplogConfig,
base_dir: &Path,
out: &Path,
) -> Vec<String> {
let mut lines = Vec::new();
let default_include_reviews = config.defaults.include_reviews.unwrap_or(false);
if let Some(source) = config
.sources
.github
.as_ref()
.filter(|source| source.enabled)
{
let identity = config_identity_label(source.user.as_deref(), source.me, "--me");
let mode =
optional_config_string(source.mode.as_deref()).unwrap_or_else(|| "merged".into());
let include_reviews = source.include_reviews.unwrap_or(default_include_reviews);
let cache = config_cache_label(
resolve_config_cache_dir(base_dir, out, source.cache_dir.as_ref(), source.no_cache)
.as_deref(),
);
lines.push(format!(
"github: {identity}, mode {mode}, include_reviews {include_reviews}, cache {cache}"
));
}
if let Some(source) = config
.sources
.gitlab
.as_ref()
.filter(|source| source.enabled)
{
let identity = config_identity_label(source.user.as_deref(), source.me, "--me");
let instance = optional_config_string(source.instance.as_deref())
.unwrap_or_else(|| "gitlab.com".into());
let state =
optional_config_string(source.state.as_deref()).unwrap_or_else(|| "merged".into());
let include_reviews = source.include_reviews.unwrap_or(default_include_reviews);
let cache = config_cache_label(
resolve_config_cache_dir(base_dir, out, source.cache_dir.as_ref(), source.no_cache)
.as_deref(),
);
lines.push(format!(
"gitlab: {identity}, instance {instance}, state {state}, include_reviews {include_reviews}, cache {cache}"
));
}
if let Some(source) = config.sources.jira.as_ref().filter(|source| source.enabled) {
let user = optional_config_string(source.user.as_deref()).unwrap_or_else(|| "-".into());
let instance =
optional_config_string(source.instance.as_deref()).unwrap_or_else(|| "-".into());
let status =
optional_config_string(source.status.as_deref()).unwrap_or_else(|| "done".into());
let auth_user = config_jira_auth_user_label(source);
let cache = config_cache_label(
resolve_config_cache_dir(base_dir, out, source.cache_dir.as_ref(), source.no_cache)
.as_deref(),
);
lines.push(format!(
"jira: user {user}, auth {auth_user}, instance {instance}, status {status}, cache {cache}"
));
}
if let Some(source) = config
.sources
.linear
.as_ref()
.filter(|source| source.enabled)
{
let user_id =
optional_config_string(source.user_id.as_deref()).unwrap_or_else(|| "-".into());
let status =
optional_config_string(source.status.as_deref()).unwrap_or_else(|| "done".into());
let project =
optional_config_string(source.project.as_deref()).unwrap_or_else(|| "-".into());
let cache = config_cache_label(
resolve_config_cache_dir(base_dir, out, source.cache_dir.as_ref(), source.no_cache)
.as_deref(),
);
lines.push(format!(
"linear: user_id {user_id}, status {status}, project {project}, cache {cache}"
));
}
if let Some(source) = config.sources.git.as_ref().filter(|source| source.enabled) {
let repo = source
.repo
.as_ref()
.map(|path| resolve_config_path(base_dir, path).display().to_string())
.unwrap_or_else(|| "-".to_string());
let author = optional_config_string(source.author.as_deref()).unwrap_or_else(|| "-".into());
lines.push(format!(
"git: repo {repo}, author {author}, include_merges {}",
source.include_merges
));
}
if let Some(source) = config.sources.json.as_ref().filter(|source| source.enabled) {
let events = source
.events
.as_ref()
.map(|path| resolve_config_path(base_dir, path).display().to_string())
.unwrap_or_else(|| "-".to_string());
let coverage = source
.coverage
.as_ref()
.map(|path| resolve_config_path(base_dir, path).display().to_string())
.unwrap_or_else(|| "-".to_string());
lines.push(format!("json: events {events}, coverage {coverage}"));
}
if let Some(source) = config
.sources
.manual
.as_ref()
.filter(|source| source.enabled)
{
let events = source
.events
.as_ref()
.map(|path| resolve_config_path(base_dir, path).display().to_string())
.unwrap_or_else(|| "-".to_string());
let user = optional_config_string(source.user.as_deref())
.or_else(|| optional_config_string(config.user.label.as_deref()))
.unwrap_or_else(|| "-".into());
lines.push(format!("manual: events {events}, user {user}"));
}
lines
}
fn config_identity_label(user: Option<&str>, me: bool, me_label: &str) -> String {
match (optional_config_string(user), me) {
(Some(user), false) => format!("user {user}"),
(None, true) => format!("identity {me_label}"),
(Some(user), true) => format!("user {user} and {me_label}"),
(None, false) => "identity -".to_string(),
}
}
fn config_jira_auth_user_label(source: &ConfigJiraSource) -> String {
if let Some(auth_user) = optional_config_string(source.auth_user.as_deref()) {
return format!("user {auth_user}");
}
if let Some(env_var) = optional_config_string(source.auth_user_env.as_deref()) {
return format!("env {env_var}");
}
"defaults to assignee".to_string()
}
fn config_cache_label(cache_dir: Option<&Path>) -> String {
cache_dir
.map(|path| path.display().to_string())
.unwrap_or_else(|| "disabled".to_string())
}
fn doctor_defaults(report: &mut DoctorReport, config: &ShiplogConfig, base_dir: &Path) {
match resolve_multi_window(ConfigWindowArgs::default(), config) {
Ok(window) => report.ok("Window", window.window_label()),
Err(err) => report.error("Window", err.to_string()),
}
let out = config
.defaults
.out
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(base_dir, path))
.unwrap_or_else(|| resolve_config_path(base_dir, Path::new("./out")));
match doctor_check_output_writable(&out) {
Ok(()) => report.ok("Output", format!("{} writable", out.display())),
Err(err) => report.error("Output", err.to_string()),
}
let profile = match doctor_config_profile(config.defaults.profile.as_deref()) {
Ok(profile) => {
report.ok("Profile", profile.as_str());
profile
}
Err(err) => {
report.error("Profile", err.to_string());
BundleProfile::Internal
}
};
let key_env = optional_config_string(config.redaction.key_env.as_deref())
.unwrap_or_else(|| "SHIPLOG_REDACT_KEY".to_string());
if matches!(profile, BundleProfile::Internal) {
report.ok("Redaction", "internal profile, no key required");
} else if env_var_present(&key_env) {
report.ok(
"Redaction",
format!("{} profile, {key_env} present", profile.as_str()),
);
} else {
report.error(
"Redaction",
format!(
"{} profile requires {key_env}; set it or use profile = \"internal\"",
profile.as_str()
),
);
}
}
fn doctor_config_profile(profile: Option<&str>) -> Result<BundleProfile> {
let profile = non_empty_string(profile).unwrap_or_else(|| "internal".to_string());
profile
.parse::<BundleProfile>()
.map_err(anyhow::Error::msg)
.with_context(|| format!("parse defaults.profile {profile:?}"))
}
fn doctor_check_output_writable(out: &Path) -> Result<()> {
let probe_dir = if out.exists() {
if !out.is_dir() {
anyhow::bail!("{} exists but is not a directory", out.display());
}
out.to_path_buf()
} else {
out.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
if !probe_dir.exists() {
anyhow::bail!(
"{} does not exist; create it or choose a different defaults.out",
probe_dir.display()
);
}
let probe = probe_dir.join(format!(".shiplog-doctor-write-test-{}", std::process::id()));
std::fs::write(&probe, b"").with_context(|| format!("write test file {}", probe.display()))?;
std::fs::remove_file(&probe)
.with_context(|| format!("remove test file {}", probe.display()))?;
Ok(())
}
fn doctor_sources(
report: &mut DoctorReport,
config: &ShiplogConfig,
base_dir: &Path,
selected_sources: &[InitSource],
) {
doctor_github(report, config, selected_sources);
doctor_gitlab(report, config, selected_sources);
doctor_jira(report, config, selected_sources);
doctor_linear(report, config, selected_sources);
doctor_git(report, config, base_dir, selected_sources);
doctor_json(report, config, base_dir, selected_sources);
doctor_manual(report, config, base_dir, selected_sources);
}
fn doctor_should_check(selected_sources: &[InitSource], source: InitSource) -> bool {
selected_sources.is_empty() || selected_sources.contains(&source)
}
fn doctor_github(report: &mut DoctorReport, config: &ShiplogConfig, selected: &[InitSource]) {
if !doctor_should_check(selected, InitSource::Github) {
return;
}
let Some(source) = config.sources.github.as_ref() else {
report.disabled("GitHub");
return;
};
if !source.enabled {
report.disabled("GitHub");
return;
}
let api_base = optional_config_string(source.api_base.as_deref())
.unwrap_or_else(|| "https://api.github.com".to_string());
let token_present = env_var_present("GITHUB_TOKEN");
match (
optional_config_string(source.user.as_deref()),
source.me,
token_present,
) {
(Some(_), true, _) => report.error("GitHub", "use either user or me, not both"),
(Some(user), false, true) => report.ok("GitHub", format!("token found, user {user}")),
(Some(user), false, false) => report.error(
"GitHub",
format!("missing GITHUB_TOKEN for configured user {user}"),
),
(None, true, true) => match discover_github_user(&api_base, None) {
Ok(user) => report.ok("GitHub", format!("token found, user inferred as {user}")),
Err(err) => report.error("GitHub", err.to_string()),
},
(None, true, false) => {
report.error("GitHub", "missing GITHUB_TOKEN for me identity discovery");
}
(None, false, _) => report.error("GitHub", "set sources.github.user or me = true"),
}
}
fn doctor_gitlab(report: &mut DoctorReport, config: &ShiplogConfig, selected: &[InitSource]) {
if !doctor_should_check(selected, InitSource::Gitlab) {
return;
}
let Some(source) = config.sources.gitlab.as_ref() else {
report.disabled("GitLab");
return;
};
if !source.enabled {
report.disabled("GitLab");
return;
}
let instance = optional_config_string(source.instance.as_deref())
.unwrap_or_else(|| "gitlab.com".to_string());
if let Err(err) = gitlab_api_base(&instance) {
report.error("GitLab", err.to_string());
return;
}
if let Some(state) = non_empty_string(source.state.as_deref())
&& let Err(err) = state.parse::<MrState>()
{
report.error("GitLab", format!("parse state {state:?}: {err}"));
return;
}
let token_present = env_var_present("GITLAB_TOKEN");
match (
optional_config_string(source.user.as_deref()),
source.me,
token_present,
) {
(Some(_), true, _) => report.error("GitLab", "use either user or me, not both"),
(Some(user), false, true) => {
report.ok(
"GitLab",
format!("token found, user {user}, instance {instance}"),
);
}
(Some(user), false, false) => report.error(
"GitLab",
format!("missing GITLAB_TOKEN for configured user {user}"),
),
(None, true, true) => match discover_gitlab_user(&instance, None) {
Ok(user) => report.ok("GitLab", format!("token found, user inferred as {user}")),
Err(err) => report.error("GitLab", err.to_string()),
},
(None, true, false) => {
report.error("GitLab", "missing GITLAB_TOKEN for me identity discovery");
}
(None, false, _) => report.error("GitLab", "set sources.gitlab.user or me = true"),
}
}
fn doctor_jira(report: &mut DoctorReport, config: &ShiplogConfig, selected: &[InitSource]) {
if !doctor_should_check(selected, InitSource::Jira) {
return;
}
let Some(source) = config.sources.jira.as_ref() else {
report.disabled("Jira");
return;
};
if !source.enabled {
report.disabled("Jira");
return;
}
let start_errors = report.errors;
let user = required_config_string("jira", "user", source.user.as_deref());
let instance = required_config_string("jira", "instance", source.instance.as_deref());
let status = source.status.as_deref().unwrap_or("done");
let mut details = Vec::new();
match user {
Ok(user) => details.push(format!("assignee {user}")),
Err(err) => report.error("Jira", err.to_string()),
}
match instance {
Ok(instance) => details.push(format!("instance {instance}")),
Err(err) => report.error("Jira", err.to_string()),
}
if let Err(err) = status.parse::<IssueStatus>() {
report.error("Jira", format!("parse status {status:?}: {err}"));
}
if env_var_present("JIRA_TOKEN") {
details.push("JIRA_TOKEN present".to_string());
} else {
report.error("Jira", "missing JIRA_TOKEN");
}
if let Some(auth_user) = optional_config_string(source.auth_user.as_deref()) {
details.push(format!("auth user {auth_user}"));
} else if let Some(env_var) = optional_config_string(source.auth_user_env.as_deref()) {
if env_var_present(&env_var) {
details.push(format!("auth user from {env_var}"));
} else {
details.push(format!("auth user defaults to assignee; {env_var} not set"));
}
} else {
details.push("auth user defaults to assignee".to_string());
}
if report.errors == start_errors {
report.ok("Jira", details.join(", "));
}
}
fn doctor_linear(report: &mut DoctorReport, config: &ShiplogConfig, selected: &[InitSource]) {
if !doctor_should_check(selected, InitSource::Linear) {
return;
}
let Some(source) = config.sources.linear.as_ref() else {
report.disabled("Linear");
return;
};
if !source.enabled {
report.disabled("Linear");
return;
}
let start_errors = report.errors;
let mut details = Vec::new();
match required_config_string("linear", "user_id", source.user_id.as_deref()) {
Ok(user_id) => details.push(format!("user_id {user_id}")),
Err(err) => report.error("Linear", err.to_string()),
}
let status = source.status.as_deref().unwrap_or("done");
if let Err(err) = status.parse::<LinearIssueStatus>() {
report.error("Linear", format!("parse status {status:?}: {err}"));
}
if env_var_present("LINEAR_API_KEY") {
details.push("LINEAR_API_KEY present".to_string());
} else {
report.error("Linear", "missing LINEAR_API_KEY");
}
if report.errors == start_errors {
report.ok("Linear", details.join(", "));
}
}
fn doctor_git(
report: &mut DoctorReport,
config: &ShiplogConfig,
base_dir: &Path,
selected: &[InitSource],
) {
if !doctor_should_check(selected, InitSource::Git) {
return;
}
let Some(source) = config.sources.git.as_ref() else {
report.disabled("Git");
return;
};
if !source.enabled {
report.disabled("Git");
return;
}
match required_config_path(base_dir, "git", "repo", source.repo.as_ref()) {
Ok(repo) if repo.is_dir() => report.ok("Git", format!("repo {}", repo.display())),
Ok(repo) => report.error("Git", format!("{} is not a directory", repo.display())),
Err(err) => report.error("Git", err.to_string()),
}
}
fn doctor_json(
report: &mut DoctorReport,
config: &ShiplogConfig,
base_dir: &Path,
selected: &[InitSource],
) {
if !doctor_should_check(selected, InitSource::Json) {
return;
}
let Some(source) = config.sources.json.as_ref() else {
report.disabled("JSON");
return;
};
if !source.enabled {
report.disabled("JSON");
return;
}
let events = required_config_path(base_dir, "json", "events", source.events.as_ref());
let coverage = required_config_path(base_dir, "json", "coverage", source.coverage.as_ref());
match (events, coverage) {
(Ok(events), Ok(coverage)) if events.exists() && coverage.exists() => report.ok(
"JSON",
format!(
"events {}, coverage {}",
events.display(),
coverage.display()
),
),
(Ok(events), Ok(_)) if !events.exists() => {
report.error("JSON", format!("{} not found", events.display()));
}
(Ok(_), Ok(coverage)) if !coverage.exists() => {
report.error("JSON", format!("{} not found", coverage.display()));
}
(Err(err), _) | (_, Err(err)) => report.error("JSON", err.to_string()),
_ => {}
}
}
fn doctor_manual(
report: &mut DoctorReport,
config: &ShiplogConfig,
base_dir: &Path,
selected: &[InitSource],
) {
if !doctor_should_check(selected, InitSource::Manual) {
return;
}
let Some(source) = config.sources.manual.as_ref() else {
report.disabled("Manual");
return;
};
if !source.enabled {
report.disabled("Manual");
return;
}
match required_config_path(base_dir, "manual", "events", source.events.as_ref()) {
Ok(events) if events.exists() => {
report.ok("Manual", format!("{} found", events.display()));
}
Ok(events) => report.error("Manual", format!("{} not found", events.display())),
Err(err) => report.error("Manual", err.to_string()),
}
}
fn env_var_present(name: &str) -> bool {
std::env::var(name)
.ok()
.is_some_and(|value| !value.trim().is_empty())
}
fn resolve_multi_window(args: ConfigWindowArgs, config: &ShiplogConfig) -> Result<ResolvedWindow> {
if date_args_has_any(&args.dates) {
return resolve_date_window(args.dates);
}
if let Some(period) = non_empty_string(args.period.as_deref()) {
return resolve_config_period(config, &period);
}
if let Some(window) = non_empty_string(config.defaults.window.as_deref()) {
return resolve_date_window(date_args_from_config_window(&window)?);
}
resolve_date_window(DateArgs::default())
}
fn resolve_config_period(config: &ShiplogConfig, period: &str) -> Result<ResolvedWindow> {
let configured = config.periods.get(period).ok_or_else(|| {
anyhow::anyhow!("unknown period {period:?}; define [periods.{period:?}] in shiplog.toml")
})?;
let has_explicit = configured.since.is_some() || configured.until.is_some();
let has_preset = non_empty_string(configured.preset.as_deref()).is_some();
if has_explicit && has_preset {
anyhow::bail!("period {period:?} should use either preset or since/until, not both");
}
if has_explicit {
let since = configured
.since
.ok_or_else(|| anyhow::anyhow!("period {period:?} must set both since and until"))?;
let until = configured
.until
.ok_or_else(|| anyhow::anyhow!("period {period:?} must set both since and until"))?;
return checked_window(since, until, WindowLabel::Explicit)
.map(|window| window.with_period(period.to_string()));
}
if let Some(preset) = non_empty_string(configured.preset.as_deref()) {
return resolve_date_window(date_args_from_config_window(&preset)?)
.map(|window| window.with_period(period.to_string()));
}
anyhow::bail!("period {period:?} must set preset or since/until")
}
fn date_args_has_any(args: &DateArgs) -> bool {
args.since.is_some()
|| args.until.is_some()
|| args.last_6_months
|| args.last_quarter
|| args.year.is_some()
}
fn date_args_from_config_window(window: &str) -> Result<DateArgs> {
let value = window.trim();
if value.eq_ignore_ascii_case("last-6-months") {
return Ok(DateArgs {
last_6_months: true,
..DateArgs::default()
});
}
if value.eq_ignore_ascii_case("last-quarter") {
return Ok(DateArgs {
last_quarter: true,
..DateArgs::default()
});
}
let year = value
.strip_prefix("year:")
.or_else(|| value.strip_prefix("year="))
.unwrap_or(value);
if year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) {
let year = year
.parse::<i32>()
.with_context(|| format!("parse configured window year {year:?}"))?;
return Ok(DateArgs {
year: Some(year),
..DateArgs::default()
});
}
anyhow::bail!(
"unsupported configured window {window:?}; use last-6-months, last-quarter, or year:YYYY"
)
}
fn non_empty_string(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn required_config_string(source: &str, field: &str, value: Option<&str>) -> Result<String> {
non_empty_string(value)
.ok_or_else(|| anyhow::anyhow!("{source} is enabled but sources.{source}.{field} is empty"))
}
fn optional_config_string(value: Option<&str>) -> Option<String> {
non_empty_string(value)
}
fn required_config_path(
base_dir: &Path,
source: &str,
field: &str,
value: Option<&PathBuf>,
) -> Result<PathBuf> {
let path = value
.filter(|path| !path.as_os_str().is_empty())
.ok_or_else(|| {
anyhow::anyhow!("{source} is enabled but sources.{source}.{field} is empty")
})?;
Ok(resolve_config_path(base_dir, path))
}
fn optional_config_path(base_dir: &Path, value: Option<&PathBuf>) -> Option<PathBuf> {
value
.filter(|path| !path.as_os_str().is_empty())
.map(|path| resolve_config_path(base_dir, path))
}
fn resolve_config_path(base_dir: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn config_base_dir(config_path: &Path) -> PathBuf {
config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
fn resolve_config_cache_dir(
base_dir: &Path,
out_root: &Path,
explicit_cache_dir: Option<&PathBuf>,
no_cache: bool,
) -> Option<PathBuf> {
if no_cache {
None
} else {
optional_config_path(base_dir, explicit_cache_dir).or_else(|| Some(out_root.join(".cache")))
}
}
fn push_configured_source_result(
successes: &mut Vec<(String, IngestOutput)>,
failures: &mut Vec<ConfiguredSourceFailure>,
name: &str,
result: Result<IngestOutput>,
) {
match result {
Ok(ingest) => successes.push((name.to_string(), ingest)),
Err(err) => failures.push(ConfiguredSourceFailure {
name: name.to_string(),
error: format!("{err:#}"),
}),
}
}
fn collect_configured_sources(
config_path: &Path,
config: &ShiplogConfig,
window: ResolvedWindow,
out_root: &Path,
) -> Result<ConfiguredSourceOutputs> {
let base_dir = config_base_dir(config_path);
let default_include_reviews = config.defaults.include_reviews.unwrap_or(false);
let mut successes = Vec::new();
let mut failures = Vec::new();
if let Some(source) = config
.sources
.github
.as_ref()
.filter(|source| source.enabled)
{
let api_base = optional_config_string(source.api_base.as_deref())
.unwrap_or_else(|| "https://api.github.com".to_string());
let user = resolve_user_or_me(
"GitHub",
optional_config_string(source.user.as_deref()),
source.me,
|| discover_github_user(&api_base, None),
)?;
let cache_dir = resolve_config_cache_dir(
&base_dir,
out_root,
source.cache_dir.as_ref(),
source.no_cache,
);
let ing = make_github_ingestor(
&user,
window.since,
window.until,
source.mode.as_deref().unwrap_or("merged"),
source.include_reviews.unwrap_or(default_include_reviews),
source.no_details,
source.throttle_ms,
None,
&api_base,
cache_dir,
)
.context("create configured GitHub ingestor")
.and_then(|ing| ing.ingest().context("collect configured GitHub source"));
push_configured_source_result(&mut successes, &mut failures, "github", ing);
}
if let Some(source) = config
.sources
.gitlab
.as_ref()
.filter(|source| source.enabled)
{
let instance = optional_config_string(source.instance.as_deref())
.unwrap_or_else(|| "gitlab.com".to_string());
let user = resolve_user_or_me(
"GitLab",
optional_config_string(source.user.as_deref()),
source.me,
|| discover_gitlab_user(&instance, None),
)?;
let cache_dir = resolve_config_cache_dir(
&base_dir,
out_root,
source.cache_dir.as_ref(),
source.no_cache,
);
let ing = make_gitlab_ingestor(
&user,
window.since,
window.until,
source.state.as_deref().unwrap_or("merged"),
&instance,
source.include_reviews.unwrap_or(default_include_reviews),
source.throttle_ms,
None,
cache_dir,
)
.context("create configured GitLab ingestor")
.and_then(|ing| ing.ingest().context("collect configured GitLab source"));
push_configured_source_result(&mut successes, &mut failures, "gitlab", ing);
}
if let Some(source) = config.sources.jira.as_ref().filter(|source| source.enabled) {
let user = required_config_string("jira", "user", source.user.as_deref())?;
let instance = required_config_string("jira", "instance", source.instance.as_deref())?;
let auth_user = optional_config_string(source.auth_user.as_deref()).or_else(|| {
source
.auth_user_env
.as_deref()
.and_then(|env_var| non_empty_string(Some(env_var)))
.and_then(|env_var| std::env::var(env_var).ok())
});
let cache_dir = resolve_config_cache_dir(
&base_dir,
out_root,
source.cache_dir.as_ref(),
source.no_cache,
);
let ing = make_jira_ingestor(
&user,
auth_user,
window.since,
window.until,
source.status.as_deref().unwrap_or("done"),
&instance,
source.throttle_ms,
None,
cache_dir,
)
.context("create configured Jira ingestor")
.and_then(|ing| ing.ingest().context("collect configured Jira source"));
push_configured_source_result(&mut successes, &mut failures, "jira", ing);
}
if let Some(source) = config
.sources
.linear
.as_ref()
.filter(|source| source.enabled)
{
let user_id = required_config_string("linear", "user_id", source.user_id.as_deref())?;
let project = optional_config_string(source.project.as_deref());
let cache_dir = resolve_config_cache_dir(
&base_dir,
out_root,
source.cache_dir.as_ref(),
source.no_cache,
);
let ing = make_linear_ingestor(
&user_id,
window.since,
window.until,
source.status.as_deref().unwrap_or("done"),
project,
source.throttle_ms,
None,
cache_dir,
)
.context("create configured Linear ingestor")
.and_then(|ing| ing.ingest().context("collect configured Linear source"));
push_configured_source_result(&mut successes, &mut failures, "linear", ing);
}
if let Some(source) = config.sources.git.as_ref().filter(|source| source.enabled) {
let repo = required_config_path(&base_dir, "git", "repo", source.repo.as_ref())?;
let result = make_git_ingestor(
&repo,
window.since,
window.until,
optional_config_string(source.author.as_deref()),
source.include_merges,
)
.ingest()
.context("collect configured git source");
push_configured_source_result(&mut successes, &mut failures, "git", result);
}
if let Some(source) = config.sources.json.as_ref().filter(|source| source.enabled) {
let events = required_config_path(&base_dir, "json", "events", source.events.as_ref())?;
let coverage =
required_config_path(&base_dir, "json", "coverage", source.coverage.as_ref())?;
let ing = JsonIngestor {
events_path: events,
coverage_path: coverage,
};
let result = ing.ingest().context("collect configured JSON source");
push_configured_source_result(&mut successes, &mut failures, "json", result);
}
if let Some(source) = config
.sources
.manual
.as_ref()
.filter(|source| source.enabled)
{
let events = required_config_path(&base_dir, "manual", "events", source.events.as_ref())?;
let user = optional_config_string(source.user.as_deref())
.or_else(|| optional_config_string(config.user.label.as_deref()))
.unwrap_or_else(|| "user".to_string());
let ing = ManualIngestor::new(&events, user, window.since, window.until);
let result = ing.ingest().context("collect configured manual source");
push_configured_source_result(&mut successes, &mut failures, "manual", result);
}
if successes.is_empty() && failures.is_empty() {
anyhow::bail!(
"No enabled sources found in {}; enable at least one [sources.<name>] section",
config_path.display()
);
}
if successes.is_empty() {
let failed = failures
.iter()
.map(|failure| format!("{}: {}", failure.name, failure.error))
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!("No configured sources collected successfully: {failed}");
}
Ok(ConfiguredSourceOutputs {
successes,
failures,
})
}
#[allow(clippy::too_many_arguments)]
fn run_configured_multi_pipeline(
config: &ShiplogConfig,
out: &Path,
window: ResolvedWindow,
bundle_profile: &BundleProfile,
conflict: MergeConflict,
configured: ConfiguredSourceOutputs,
regen: bool,
preserve_prior_curation: bool,
zip: bool,
engine: &Engine<'_>,
redactor: &DeterministicRedactor,
) -> Result<ConfiguredRunResult> {
let ingest_outputs = configured
.successes
.iter()
.map(|(_, ingest)| ingest.clone())
.collect::<Vec<_>>();
let mut merged = engine
.merge(ingest_outputs, conflict.into())
.context("merge configured source outputs")?;
let merge_user = config_user_label(config).unwrap_or_else(|| merged.coverage.user.clone());
let window_label = window.window_label();
merged.coverage.user = merge_user.clone();
merged.coverage.window = TimeWindow {
since: window.since,
until: window.until,
};
if !configured.failures.is_empty() {
for failure in &configured.failures {
if !merged.coverage.sources.contains(&failure.name) {
merged.coverage.sources.push(failure.name.clone());
}
merged.coverage.warnings.push(format!(
"Configured source {} was skipped: {}",
failure.name, failure.error
));
}
merged.coverage.sources.sort();
merged.coverage.sources.dedup();
merged.coverage.completeness = shiplog_schema::coverage::Completeness::Partial;
}
let run_id = merged.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
if regen {
let suggested = shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let prior_curation = if preserve_prior_curation {
preserve_prior_curated_workstreams(out, &run_dir)?
} else {
None
};
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(
merged,
&merge_user,
&window_label,
&run_dir,
zip,
bundle_profile,
)
.context("run configured multi-source pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
Ok(ConfiguredRunResult {
configured,
outputs,
ws_source,
run_id,
window,
prior_curation,
})
}
fn preserve_prior_curated_workstreams(out: &Path, run_dir: &Path) -> Result<Option<PriorCuration>> {
let destination_path = shiplog_workstreams::WorkstreamManager::curated_path(run_dir);
if destination_path.exists() {
return Ok(Some(PriorCuration {
source_run_dir: run_dir.to_path_buf(),
source_path: destination_path.clone(),
destination_path,
copied: false,
}));
}
let Some((source_run_dir, source_path)) = latest_prior_curated_workstreams(out, run_dir)?
else {
return Ok(None);
};
std::fs::create_dir_all(run_dir)
.with_context(|| format!("create intake run directory {run_dir:?}"))?;
std::fs::copy(&source_path, &destination_path).with_context(|| {
format!(
"copy prior curated workstreams from {:?} to {:?}",
source_path, destination_path
)
})?;
Ok(Some(PriorCuration {
source_run_dir,
source_path,
destination_path,
copied: true,
}))
}
fn latest_prior_curated_workstreams(
out: &Path,
current_run_dir: &Path,
) -> Result<Option<(PathBuf, PathBuf)>> {
if !out.exists() {
return Ok(None);
}
let mut candidates = Vec::new();
for entry in std::fs::read_dir(out).with_context(|| format!("read output directory {out:?}"))? {
let entry = entry?;
let run_dir = entry.path();
if !run_dir.is_dir() || same_path(&run_dir, current_run_dir) {
continue;
}
if !run_dir.join("ledger.events.jsonl").exists() {
continue;
}
let curated_path = shiplog_workstreams::WorkstreamManager::curated_path(&run_dir);
if !curated_path.exists() {
continue;
}
let modified = curated_path
.metadata()
.and_then(|metadata| metadata.modified())
.ok();
candidates.push((modified, run_dir, curated_path));
}
candidates.sort_by(|left, right| right.0.cmp(&left.0));
Ok(candidates
.into_iter()
.next()
.map(|(_, run_dir, curated_path)| (run_dir, curated_path)))
}
fn same_path(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (left.canonicalize(), right.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn config_user_label(config: &ShiplogConfig) -> Option<String> {
optional_config_string(config.user.label.as_deref())
}
fn resolve_user_or_me(
source: &str,
explicit_user: Option<String>,
me: bool,
discover: impl FnOnce() -> Result<String>,
) -> Result<String> {
match (explicit_user, me) {
(Some(_), true) => anyhow::bail!("use either --user or --me for {source}, not both"),
(Some(user), false) => Ok(user),
(None, true) => discover(),
(None, false) => {
anyhow::bail!("provide --user <username> or --me for {source}")
}
}
}
#[derive(Deserialize)]
struct GithubAuthenticatedUser {
login: String,
}
#[derive(Deserialize)]
struct GitlabAuthenticatedUser {
username: String,
}
#[derive(Deserialize)]
struct JiraAuthenticatedUser {
#[serde(rename = "accountId")]
account_id: String,
#[serde(rename = "displayName")]
display_name: Option<String>,
#[serde(rename = "emailAddress")]
email_address: Option<String>,
active: Option<bool>,
}
#[derive(Serialize)]
struct LinearGraphqlRequest<'a> {
query: &'a str,
}
#[derive(Deserialize)]
struct LinearGraphqlResponse<T> {
data: Option<T>,
errors: Option<Vec<LinearGraphqlError>>,
}
#[derive(Deserialize)]
struct LinearGraphqlError {
message: Option<String>,
}
#[derive(Deserialize)]
struct LinearViewerData {
viewer: LinearViewer,
}
#[derive(Deserialize)]
struct LinearViewer {
id: String,
name: Option<String>,
email: Option<String>,
}
fn discover_github_user(api_base: &str, token: Option<&str>) -> Result<String> {
let token = token
.map(ToOwned::to_owned)
.or_else(|| std::env::var("GITHUB_TOKEN").ok())
.ok_or_else(|| {
anyhow::anyhow!("Could not infer GitHub user: --me requires --token or GITHUB_TOKEN")
})?;
let client = identity_client()?;
let url = format!("{}/user", api_base.trim_end_matches('/'));
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.bearer_auth(token)
.send()
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
anyhow::bail!("Could not infer GitHub user: GitHub API error {status}: {body}");
}
let user = resp
.json::<GithubAuthenticatedUser>()
.with_context(|| format!("parse GitHub authenticated user from {url}"))?;
if user.login.trim().is_empty() {
anyhow::bail!("Could not infer GitHub user: authenticated user response had empty login");
}
Ok(user.login)
}
fn discover_gitlab_user(instance: &str, token: Option<&str>) -> Result<String> {
let token = token
.map(ToOwned::to_owned)
.or_else(|| std::env::var("GITLAB_TOKEN").ok())
.ok_or_else(|| {
anyhow::anyhow!("Could not infer GitLab user: --me requires --token or GITLAB_TOKEN")
})?;
let client = identity_client()?;
let url = format!("{}/user", gitlab_api_base(instance)?);
let resp = client
.get(&url)
.header("Accept", "application/json")
.header("PRIVATE-TOKEN", token)
.send()
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
anyhow::bail!("Could not infer GitLab user: GitLab API error {status}: {body}");
}
let user = resp
.json::<GitlabAuthenticatedUser>()
.with_context(|| format!("parse GitLab authenticated user from {url}"))?;
if user.username.trim().is_empty() {
anyhow::bail!(
"Could not infer GitLab user: authenticated user response had empty username"
);
}
Ok(user.username)
}
fn run_identify_jira(
instance: String,
auth_user: Option<String>,
token: Option<String>,
) -> Result<()> {
let auth_user = arg_or_env(
"Jira identity lookup",
"--auth-user",
auth_user.as_deref(),
"JIRA_AUTH_USER",
)?;
let token = arg_or_env(
"Jira identity lookup",
"--token",
token.as_deref(),
"JIRA_TOKEN",
)?;
let identity = identify_jira_user(&instance, &auth_user, &token)?;
println!("Jira identity:");
println!("- account_id: {}", identity.account_id);
println!(
"- display_name: {}",
optional_output(identity.display_name.as_deref())
);
println!(
"- email: {}",
optional_output(identity.email_address.as_deref())
);
if let Some(active) = identity.active {
println!("- active: {active}");
}
println!();
println!("Use for collection:");
println!(
" shiplog collect jira --instance {} --user {} --auth-user {}",
normalize_jira_instance(&instance)?,
identity.account_id,
auth_user
);
Ok(())
}
fn run_identify_linear(api_key: Option<String>) -> Result<()> {
let api_key = arg_or_env(
"Linear identity lookup",
"--api-key",
api_key.as_deref(),
"LINEAR_API_KEY",
)?;
let viewer = identify_linear_user(&api_key)?;
println!("Linear identity:");
println!("- user_id: {}", viewer.id);
println!("- name: {}", optional_output(viewer.name.as_deref()));
println!("- email: {}", optional_output(viewer.email.as_deref()));
println!();
println!("Use for collection:");
println!(" shiplog collect linear --user-id {}", viewer.id);
Ok(())
}
fn identify_jira_user(
instance: &str,
auth_user: &str,
token: &str,
) -> Result<JiraAuthenticatedUser> {
let client = identity_client()?;
let url = jira_api_url(instance, "/myself")?;
let resp = client
.get(&url)
.header("Accept", "application/json")
.basic_auth(auth_user, Some(token))
.send()
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
if status.as_u16() == 401 {
anyhow::bail!("Could not identify Jira user: Jira authentication failed");
}
anyhow::bail!("Could not identify Jira user: Jira API error {status}: {body}");
}
let user = resp
.json::<JiraAuthenticatedUser>()
.with_context(|| format!("parse Jira authenticated user from {url}"))?;
if user.account_id.trim().is_empty() {
anyhow::bail!("Could not identify Jira user: response had empty accountId");
}
Ok(user)
}
fn identify_linear_user(api_key: &str) -> Result<LinearViewer> {
let client = identity_client()?;
let query = "query Me { viewer { id name email } }";
let resp = client
.post(linear_graphql_url())
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", api_key)
.json(&LinearGraphqlRequest { query })
.send()
.context("POST Linear viewer query")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
if status.as_u16() == 401 {
anyhow::bail!("Could not identify Linear user: Linear authentication failed");
}
anyhow::bail!("Could not identify Linear user: Linear API error {status}: {body}");
}
let response = resp
.json::<LinearGraphqlResponse<LinearViewerData>>()
.context("parse Linear viewer response")?;
if let Some(errors) = response.errors {
anyhow::bail!(
"Could not identify Linear user: Linear GraphQL errors: {}",
errors
.iter()
.map(|error| error.message.as_deref().unwrap_or("unknown error"))
.collect::<Vec<_>>()
.join(", ")
);
}
let viewer = response
.data
.ok_or_else(|| anyhow::anyhow!("Could not identify Linear user: response missing data"))?
.viewer;
if viewer.id.trim().is_empty() {
anyhow::bail!("Could not identify Linear user: response had empty viewer id");
}
Ok(viewer)
}
fn arg_or_env(
command: &str,
arg_name: &str,
explicit: Option<&str>,
env_name: &str,
) -> Result<String> {
optional_config_string(explicit)
.or_else(|| {
std::env::var(env_name)
.ok()
.and_then(|value| non_empty_string(Some(&value)))
})
.ok_or_else(|| anyhow::anyhow!("{command} requires {arg_name} or {env_name}"))
}
fn optional_output(value: Option<&str>) -> &str {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("-")
}
fn jira_api_url(instance: &str, path: &str) -> Result<String> {
Ok(format!(
"https://{}/rest/api/3{}",
normalize_jira_instance(instance)?,
path
))
}
fn normalize_jira_instance(instance: &str) -> Result<String> {
if instance.trim().is_empty() {
anyhow::bail!("Jira instance cannot be empty");
}
if instance.contains("://") {
return reqwest::Url::parse(instance)
.ok()
.and_then(|url| url.host_str().map(ToOwned::to_owned))
.ok_or_else(|| anyhow::anyhow!("Invalid Jira instance URL: {instance}"));
}
Ok(instance.trim().trim_end_matches('/').to_string())
}
fn linear_graphql_url() -> &'static str {
"https://api.linear.app/graphql"
}
fn gitlab_api_base(instance: &str) -> Result<String> {
if instance.trim().is_empty() {
anyhow::bail!("GitLab instance cannot be empty");
}
let hostname = if instance.contains("://") {
reqwest::Url::parse(instance)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow::anyhow!("Invalid GitLab instance URL: {instance}"))?
} else {
instance.to_string()
};
Ok(format!("https://{hostname}/api/v4"))
}
fn identity_client() -> Result<Client> {
Client::builder()
.user_agent(concat!("shiplog/", env!("CARGO_PKG_VERSION")))
.build()
.context("build identity discovery HTTP client")
}
fn create_engine(
redact_key: &str,
clusterer: Box<dyn shiplog_ports::WorkstreamClusterer>,
bundle_profile: &BundleProfile,
) -> (Engine<'static>, &'static DeterministicRedactor) {
create_engine_with_renderer(
redact_key,
clusterer,
Box::new(ModeMarkdownRenderer::new(
RenderPacketMode::Packet,
cli_render_options(RenderPacketMode::Packet, None, None, bundle_profile),
)),
)
}
fn create_engine_with_renderer(
redact_key: &str,
clusterer: Box<dyn shiplog_ports::WorkstreamClusterer>,
renderer: Box<dyn Renderer>,
) -> (Engine<'static>, &'static DeterministicRedactor) {
let redactor = DeterministicRedactor::new(redact_key.as_bytes());
let renderer: &'static dyn Renderer = Box::leak(renderer);
let clusterer: &'static dyn shiplog_ports::WorkstreamClusterer = Box::leak(clusterer);
let redactor_box = Box::new(redactor);
let redactor_ref: &'static DeterministicRedactor = Box::leak(redactor_box);
let redactor_trait: &'static dyn shiplog_ports::Redactor = redactor_ref;
(
Engine::new(renderer, clusterer, redactor_trait),
redactor_ref,
)
}
struct ModeMarkdownRenderer {
mode: RenderPacketMode,
options: MarkdownRenderOptions,
inner: MarkdownRenderer,
}
impl ModeMarkdownRenderer {
fn new(mode: RenderPacketMode, options: MarkdownRenderOptions) -> Self {
Self {
mode,
options,
inner: cli_packet_renderer(),
}
}
}
fn cli_packet_renderer() -> MarkdownRenderer {
MarkdownRenderer::new().with_section_order(SectionOrder::CoverageFirst)
}
const MANAGER_RECEIPT_RENDER_LIMIT: usize = 3;
const PUBLIC_RECEIPT_RENDER_LIMIT: usize = 1;
struct RenderExistingArgs<'a> {
out: &'a Path,
run: Option<String>,
latest: bool,
user: Option<&'a str>,
window_label: Option<&'a str>,
redaction_key: RedactionKey,
bundle_profile: BundleProfile,
mode: RenderPacketMode,
receipt_limit: Option<usize>,
appendix: Option<RenderAppendixMode>,
zip: bool,
}
fn cli_render_options(
mode: RenderPacketMode,
receipt_limit: Option<usize>,
appendix: Option<RenderAppendixMode>,
bundle_profile: &BundleProfile,
) -> MarkdownRenderOptions {
MarkdownRenderOptions {
receipt_limit: receipt_limit
.unwrap_or_else(|| default_receipt_limit_for_profile(mode, bundle_profile)),
appendix_mode: appendix
.unwrap_or_else(|| default_appendix_for_profile(mode, bundle_profile))
.into(),
}
}
fn default_receipt_limit_for_profile(
mode: RenderPacketMode,
bundle_profile: &BundleProfile,
) -> usize {
match (mode, bundle_profile) {
(RenderPacketMode::Packet, BundleProfile::Manager) => MANAGER_RECEIPT_RENDER_LIMIT,
(RenderPacketMode::Packet, BundleProfile::Public) => PUBLIC_RECEIPT_RENDER_LIMIT,
_ => WORKSTREAM_RECEIPT_RENDER_LIMIT,
}
}
fn default_appendix_for_profile(
mode: RenderPacketMode,
bundle_profile: &BundleProfile,
) -> RenderAppendixMode {
match mode {
RenderPacketMode::Packet => match bundle_profile {
BundleProfile::Internal | BundleProfile::Manager => RenderAppendixMode::Summary,
BundleProfile::Public => RenderAppendixMode::None,
},
RenderPacketMode::Scaffold => RenderAppendixMode::None,
RenderPacketMode::Receipts => RenderAppendixMode::Full,
}
}
fn render_existing_run(args: RenderExistingArgs<'_>) -> Result<shiplog_engine::RunOutputs> {
let clusterer: Box<dyn shiplog_ports::WorkstreamClusterer> = Box::new(RepoClusterer);
let renderer = Box::new(ModeMarkdownRenderer::new(
args.mode,
cli_render_options(
args.mode,
args.receipt_limit,
args.appendix,
&args.bundle_profile,
),
));
let (engine, redactor) =
create_engine_with_renderer(args.redaction_key.engine_key(), clusterer, renderer);
let engine = engine.with_profile_rendering(args.redaction_key.render_profiles());
let run_dir = resolve_render_run_dir(args.out, args.run, args.latest)?;
let events_path = run_dir.join("ledger.events.jsonl");
let coverage_path = run_dir.join("coverage.manifest.json");
if !events_path.exists() {
anyhow::bail!(
"No ledger.events.jsonl found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let ing = JsonIngestor {
events_path,
coverage_path,
};
let ingest = ing.ingest().context("ingest events")?;
let render_user = args
.user
.map(str::to_string)
.unwrap_or_else(|| ingest.coverage.user.clone());
let render_window_label = args.window_label.map(str::to_string).unwrap_or_else(|| {
format!(
"{}..{}",
ingest.coverage.window.since, ingest.coverage.window.until
)
});
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let outputs = engine
.refresh(
ingest,
&render_user,
&render_window_label,
&run_dir,
args.zip,
&args.bundle_profile,
)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
Ok(outputs)
}
impl Renderer for ModeMarkdownRenderer {
fn render_packet_markdown(
&self,
user: &str,
window_label: &str,
events: &[EventEnvelope],
workstreams: &WorkstreamsFile,
coverage: &shiplog_schema::coverage::CoverageManifest,
) -> Result<String> {
match self.mode {
RenderPacketMode::Packet => self.inner.render_packet_markdown_with_options(
user,
window_label,
events,
workstreams,
coverage,
self.options,
),
RenderPacketMode::Scaffold => self.inner.render_scaffold_markdown_with_options(
user,
window_label,
events,
workstreams,
coverage,
self.options,
),
RenderPacketMode::Receipts => self.inner.render_receipts_markdown_with_options(
user,
window_label,
events,
workstreams,
coverage,
self.options,
),
}
}
}
fn build_clusterer(
llm_cluster: bool,
llm_api_endpoint: &str,
llm_model: &str,
llm_api_key: Option<String>,
) -> Box<dyn shiplog_ports::WorkstreamClusterer> {
if llm_cluster {
#[cfg(feature = "llm")]
{
eprintln!(
"WARN: --llm-cluster sends event summaries (PR titles, repo names) to {llm_api_endpoint}"
);
let api_key = llm_api_key
.or_else(|| std::env::var("SHIPLOG_LLM_API_KEY").ok())
.unwrap_or_else(|| {
eprintln!("ERROR: --llm-cluster requires --llm-api-key or SHIPLOG_LLM_API_KEY");
std::process::exit(1);
});
let backend = shiplog_cluster_llm::OpenAiCompatibleBackend {
endpoint: llm_api_endpoint.to_string(),
api_key,
model: llm_model.to_string(),
temperature: 0.2,
timeout_secs: 60,
};
let config = shiplog_cluster_llm::LlmConfig {
api_endpoint: llm_api_endpoint.to_string(),
api_key: String::new(),
model: llm_model.to_string(),
..Default::default()
};
let llm = shiplog_cluster_llm::LlmClusterer::new(Box::new(backend), config);
Box::new(shiplog_cluster_llm::LlmWithFallback::new(llm))
}
#[cfg(not(feature = "llm"))]
{
let _ = (llm_api_endpoint, llm_model, llm_api_key);
eprintln!(
"ERROR: --llm-cluster requires the 'llm' feature. Rebuild with: cargo build -p shiplog --features llm"
);
std::process::exit(1);
}
} else {
Box::new(RepoClusterer)
}
}
fn resolve_cache_dir(
out_root: &Path,
explicit_cache_dir: Option<PathBuf>,
no_cache: bool,
) -> Option<PathBuf> {
if no_cache {
None
} else {
Some(explicit_cache_dir.unwrap_or_else(|| out_root.join(".cache")))
}
}
#[derive(Debug, Clone)]
struct CacheDbTarget {
source: CacheSource,
path: PathBuf,
}
#[derive(Debug)]
enum CacheCleanMode {
Expired,
OlderThan(DateTime<Utc>),
All,
}
fn run_cache_stats(args: CacheArgs) -> Result<()> {
let root = cache_command_root(&args.out, args.cache_dir.as_ref());
println!("Cache root: {}", root.display());
let targets = cache_db_targets(&root, &args.sources);
let mut found = 0usize;
for target in targets {
if !target.path.exists() {
println!(
"{}: missing, {}",
target.source.as_str(),
target.path.display()
);
continue;
}
found += 1;
let cache = ApiCache::open_read_only(&target.path)
.with_context(|| format!("open cache {}", target.path.display()))?;
let stats = cache
.stats()
.with_context(|| format!("read cache stats {}", target.path.display()))?;
print_cache_stats(target.source, &target.path, &stats);
}
if found == 0 {
println!("No cache databases found");
}
Ok(())
}
fn run_cache_inspect(args: CacheArgs) -> Result<()> {
let root = cache_command_root(&args.out, args.cache_dir.as_ref());
println!("Cache root: {}", root.display());
let targets = cache_db_targets(&root, &args.sources);
let mut found = 0usize;
for target in targets {
if !target.path.exists() {
println!(
"{}: missing, {}",
target.source.as_str(),
target.path.display()
);
continue;
}
found += 1;
let cache = ApiCache::open_read_only(&target.path)
.with_context(|| format!("open cache {}", target.path.display()))?;
let inspection = cache
.inspect()
.with_context(|| format!("inspect cache {}", target.path.display()))?;
print_cache_stats(target.source, &target.path, &inspection.stats);
println!(
" oldest: {}",
inspection.oldest_cached_at.as_deref().unwrap_or("-")
);
println!(
" newest: {}",
inspection.newest_cached_at.as_deref().unwrap_or("-")
);
}
if found == 0 {
println!("No cache databases found");
}
Ok(())
}
fn run_cache_clean(args: CacheCleanArgs) -> Result<()> {
let mode = cache_clean_mode(&args)?;
if matches!(mode, CacheCleanMode::All) && !args.yes && !args.dry_run {
anyhow::bail!("cache clean --all requires --yes");
}
let root = cache_command_root(&args.out, args.cache_dir.as_ref());
println!("Cache root: {}", root.display());
let targets = cache_db_targets(&root, &args.sources);
let mut found = 0usize;
for target in targets {
if !target.path.exists() {
println!(
"{}: missing, {}",
target.source.as_str(),
target.path.display()
);
continue;
}
found += 1;
let cache = ApiCache::open(&target.path)
.with_context(|| format!("open cache {}", target.path.display()))?;
let planned = cache_clean_count(&cache, &mode)?;
if args.dry_run {
println!(
"{}: would remove {} entries from {}",
target.source.as_str(),
planned,
target.path.display()
);
continue;
}
let removed = cache_clean_apply(&cache, &mode, planned)?;
println!(
"{}: removed {} entries from {}",
target.source.as_str(),
removed,
target.path.display()
);
}
if found == 0 {
println!("No cache databases found");
}
Ok(())
}
fn cache_command_root(out: &Path, cache_dir: Option<&PathBuf>) -> PathBuf {
cache_dir.cloned().unwrap_or_else(|| out.join(".cache"))
}
fn cache_db_targets(root: &Path, sources: &[CacheSource]) -> Vec<CacheDbTarget> {
selected_cache_sources(sources)
.into_iter()
.map(|source| CacheDbTarget {
source,
path: root.join(source.db_filename()),
})
.collect()
}
fn selected_cache_sources(sources: &[CacheSource]) -> Vec<CacheSource> {
if sources.is_empty() {
return CacheSource::all().to_vec();
}
let mut selected = Vec::new();
for source in sources {
if !selected.contains(source) {
selected.push(*source);
}
}
selected
}
fn print_cache_stats(source: CacheSource, path: &Path, stats: &shiplog_cache::CacheStats) {
println!("{}:", source.as_str());
println!(" path: {}", path.display());
println!(
" entries: total {}, valid {}, expired {}",
stats.total_entries, stats.valid_entries, stats.expired_entries
);
println!(" size: {} MB", stats.cache_size_mb);
}
fn cache_clean_mode(args: &CacheCleanArgs) -> Result<CacheCleanMode> {
if args.all && args.older_than.is_some() {
anyhow::bail!("use either --all or --older-than, not both");
}
if args.all {
return Ok(CacheCleanMode::All);
}
if let Some(age) = args.older_than.as_deref() {
let duration = parse_cache_age(age)?;
return Ok(CacheCleanMode::OlderThan(Utc::now() - duration));
}
Ok(CacheCleanMode::Expired)
}
fn parse_cache_age(value: &str) -> Result<Duration> {
let value = value.trim();
let Some(unit) = value.chars().last() else {
anyhow::bail!("--older-than must use a duration like 30d, 12h, or 90m");
};
let amount = &value[..value.len() - unit.len_utf8()];
let amount: i64 = amount
.parse()
.with_context(|| format!("parse --older-than duration {value:?}"))?;
if amount < 0 {
anyhow::bail!("--older-than must not be negative");
}
match unit {
'd' => Ok(Duration::days(amount)),
'h' => Ok(Duration::hours(amount)),
'm' => Ok(Duration::minutes(amount)),
_ => anyhow::bail!("--older-than must use d, h, or m, got {unit:?}"),
}
}
fn cache_clean_count(cache: &ApiCache, mode: &CacheCleanMode) -> Result<usize> {
match mode {
CacheCleanMode::Expired => Ok(cache.stats()?.expired_entries),
CacheCleanMode::OlderThan(cutoff) => cache.count_older_than(*cutoff),
CacheCleanMode::All => Ok(cache.stats()?.total_entries),
}
}
fn cache_clean_apply(cache: &ApiCache, mode: &CacheCleanMode, planned: usize) -> Result<usize> {
match mode {
CacheCleanMode::Expired => cache.cleanup_expired(),
CacheCleanMode::OlderThan(cutoff) => cache.cleanup_older_than(*cutoff),
CacheCleanMode::All => {
cache.clear()?;
Ok(planned)
}
}
}
#[allow(clippy::too_many_arguments)]
fn make_github_ingestor(
user: &str,
since: NaiveDate,
until: NaiveDate,
mode: &str,
include_reviews: bool,
no_details: bool,
throttle_ms: u64,
token: Option<String>,
api_base: &str,
cache_dir: Option<PathBuf>,
) -> Result<GithubIngestor> {
let token = token.or_else(|| std::env::var("GITHUB_TOKEN").ok());
let mut ing = GithubIngestor::new(user.to_string(), since, until);
ing.mode = mode.to_string();
ing.include_reviews = include_reviews;
ing.fetch_details = !no_details;
ing.throttle_ms = throttle_ms;
ing.token = token;
ing.api_base = api_base.to_string();
if let Some(cache_dir) = cache_dir {
ing = ing
.with_cache(cache_dir)
.context("configure GitHub API cache")?;
}
Ok(ing)
}
#[allow(clippy::too_many_arguments)]
fn make_gitlab_ingestor(
user: &str,
since: NaiveDate,
until: NaiveDate,
state: &str,
instance: &str,
include_reviews: bool,
throttle_ms: u64,
token: Option<String>,
cache_dir: Option<PathBuf>,
) -> Result<GitlabIngestor> {
let token = token.or_else(|| std::env::var("GITLAB_TOKEN").ok());
let state = state
.parse::<MrState>()
.with_context(|| format!("parse GitLab MR state {state:?}"))?;
let mut ing = GitlabIngestor::new(user.to_string(), since, until)
.with_state(state)
.with_include_reviews(include_reviews)
.with_throttle(throttle_ms)
.with_instance(instance.to_string())
.context("configure GitLab instance")?;
if let Some(token) = token {
ing = ing.with_token(token).context("configure GitLab token")?;
}
if let Some(cache_dir) = cache_dir {
ing = ing
.with_cache(cache_dir)
.context("configure GitLab API cache")?;
}
Ok(ing)
}
#[allow(clippy::too_many_arguments)]
fn make_jira_ingestor(
user: &str,
auth_user: Option<String>,
since: NaiveDate,
until: NaiveDate,
status: &str,
instance: &str,
throttle_ms: u64,
token: Option<String>,
cache_dir: Option<PathBuf>,
) -> Result<JiraIngestor> {
let token = token.or_else(|| std::env::var("JIRA_TOKEN").ok());
let status = status
.parse::<IssueStatus>()
.with_context(|| format!("parse Jira issue status {status:?}"))?;
let mut ing = JiraIngestor::new(user.to_string(), since, until)
.with_status(status)
.with_throttle(throttle_ms)
.with_instance(instance.to_string())
.context("configure Jira instance")?;
if let Some(auth_user) = auth_user {
ing = ing
.with_auth_user(auth_user)
.context("configure Jira auth user")?;
}
if let Some(token) = token {
ing = ing.with_token(token).context("configure Jira token")?;
}
if let Some(cache_dir) = cache_dir {
ing = ing
.with_cache(cache_dir)
.context("configure Jira API cache")?;
}
Ok(ing)
}
#[allow(clippy::too_many_arguments)]
fn make_linear_ingestor(
user_id: &str,
since: NaiveDate,
until: NaiveDate,
status: &str,
project: Option<String>,
throttle_ms: u64,
api_key: Option<String>,
cache_dir: Option<PathBuf>,
) -> Result<LinearIngestor> {
let api_key = api_key.or_else(|| std::env::var("LINEAR_API_KEY").ok());
let status = status
.parse::<LinearIssueStatus>()
.with_context(|| format!("parse Linear issue status {status:?}"))?;
let mut ing = LinearIngestor::new(user_id.to_string(), since, until)
.with_status(status)
.with_throttle(throttle_ms);
if let Some(project) = project {
ing = ing.with_project(project);
}
if let Some(api_key) = api_key {
ing = ing
.with_api_key(api_key)
.context("configure Linear API key")?;
}
if let Some(cache_dir) = cache_dir {
ing = ing
.with_cache(cache_dir)
.context("configure Linear API cache")?;
}
Ok(ing)
}
fn make_git_ingestor(
repo: &Path,
since: NaiveDate,
until: NaiveDate,
author: Option<String>,
include_merges: bool,
) -> LocalGitIngestor {
let mut ing = LocalGitIngestor::new(repo, since, until);
if let Some(author) = author {
ing = ing.with_author(author);
}
if include_merges {
ing = ing.with_merges(true);
}
ing
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Command::Init {
sources,
dry_run,
force,
} => {
run_init(sources, dry_run, force)?;
}
Command::Doctor { config, sources } => {
run_doctor(&config, &sources)?;
}
Command::Intake(args) => {
run_intake(args)?;
}
Command::Config { cmd } => match cmd {
ConfigCommand::Validate { config } => {
run_config_validate(&config)?;
}
ConfigCommand::Explain { config } => {
run_config_explain(&config)?;
}
ConfigCommand::Migrate { config, dry_run } => {
run_config_migrate(&config, dry_run)?;
}
},
Command::Cache { cmd } => match cmd {
CacheCommand::Stats(args) => run_cache_stats(args)?,
CacheCommand::Inspect(args) => run_cache_inspect(args)?,
CacheCommand::Clean(args) => run_cache_clean(args)?,
},
Command::Identify { cmd } => match cmd {
IdentifyCommand::Jira {
instance,
auth_user,
token,
} => run_identify_jira(instance, auth_user, token)?,
IdentifyCommand::Linear { api_key } => run_identify_linear(api_key)?,
},
Command::Journal { cmd } => match cmd {
JournalCommand::Add(args) => run_journal_add(args)?,
JournalCommand::List(args) => run_journal_list(args)?,
JournalCommand::Edit(args) => run_journal_edit(args)?,
},
Command::Collect {
source,
out,
zip,
redact_key,
bundle_profile,
regen,
llm_cluster,
llm_api_endpoint,
llm_model,
llm_api_key,
} => {
let source = match source {
CollectSource::Multi {
config,
window,
conflict,
} => {
let config_model = load_shiplog_config(&config)?;
ensure_supported_config_version(&config_model)?;
let base_dir = config_base_dir(&config);
let out = out
.clone()
.unwrap_or_else(|| config_default_out(&config_model, &base_dir));
let bundle_profile = bundle_profile.clone().map(Ok).unwrap_or_else(|| {
doctor_config_profile(config_model.defaults.profile.as_deref())
})?;
let redaction_key = RedactionKey::resolve_with_env(
redact_key.clone(),
&bundle_profile,
&config_redaction_key_env(&config_model),
)?;
let clusterer = build_clusterer(
llm_cluster,
&llm_api_endpoint,
&llm_model,
llm_api_key.clone(),
);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
let window = resolve_multi_window(window, &config_model)?;
let configured =
collect_configured_sources(&config, &config_model, window.clone(), &out)?;
let result = run_configured_multi_pipeline(
&config_model,
&out,
window,
&bundle_profile,
conflict,
configured,
regen,
false,
zip,
&engine,
redactor,
)?;
println!("Collected configured sources:");
for (name, ingest) in &result.configured.successes {
println!(
"- {name}: success, {}",
event_count_phrase(ingest.events.len())
);
}
for failure in &result.configured.failures {
println!("- {}: skipped, {}", failure.name, failure.error);
}
println!("Merged and wrote:");
println!("- inputs: {}", result.configured.successes.len());
println!("- conflict: {}", conflict.as_str());
print_outputs(&result.outputs, result.ws_source);
return Ok(());
}
CollectSource::Source(source) => source,
};
let out = out.unwrap_or_else(|| PathBuf::from("./out"));
let bundle_profile = bundle_profile.unwrap_or_default();
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
match source {
Source::Github {
user,
me,
window,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitHub", user, me, || {
discover_github_user(&api_base, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_github_ingestor(
&user,
window.since,
window.until,
&mode,
include_reviews,
no_details,
throttle_ms,
token,
&api_base,
cache_dir,
)
.context("create GitHub ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Gitlab {
user,
me,
window,
state,
instance,
include_reviews,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitLab", user, me, || {
discover_gitlab_user(&instance, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_gitlab_ingestor(
&user,
window.since,
window.until,
&state,
&instance,
include_reviews,
throttle_ms,
token,
cache_dir,
)
.context("create GitLab ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Jira {
user,
auth_user,
window,
status,
instance,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_jira_ingestor(
&user,
auth_user,
window.since,
window.until,
&status,
&instance,
throttle_ms,
token,
cache_dir,
)
.context("create Jira ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Linear {
user_id,
window,
status,
project,
throttle_ms,
api_key,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_linear_ingestor(
&user_id,
window.since,
window.until,
&status,
project,
throttle_ms,
api_key,
cache_dir,
)
.context("create Linear ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(
ingest,
&user_id,
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Json {
events,
coverage,
user,
window_label,
} => {
let ing = JsonIngestor {
events_path: events,
coverage_path: coverage,
};
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Manual {
events,
user,
window,
} => {
let window = resolve_date_window(window)?;
let ing =
ManualIngestor::new(&events, user.clone(), window.since, window.until);
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
Source::Git {
repo,
window,
author,
include_merges,
} => {
let window = resolve_date_window(window)?;
let ing = make_git_ingestor(
&repo,
window.since,
window.until,
author,
include_merges,
);
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
if !regen && shiplog_workstreams::WorkstreamManager::has_curated(&run_dir) {
eprintln!("Note: Using existing workstreams.yaml (user-curated).");
eprintln!(" Use --regen to regenerate suggestions.");
}
if regen {
let suggested =
shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(
ingest,
"local",
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Collected and wrote:");
print_outputs(&outputs, ws_source);
}
}
}
Command::Render {
out,
run,
latest,
user,
window_label,
redact_key,
bundle_profile,
mode,
receipt_limit,
appendix,
zip,
} => {
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let outputs = render_existing_run(RenderExistingArgs {
out: &out,
run,
latest,
user: Some(&user),
window_label: Some(&window_label),
redaction_key,
bundle_profile: bundle_profile.clone(),
mode,
receipt_limit,
appendix,
zip,
})?;
println!("Rendered from existing events:");
print_outputs(&outputs, WorkstreamSource::Curated);
}
Command::Share { cmd } => match cmd {
ShareCommand::Manager(options) => {
let bundle_profile = BundleProfile::Manager;
let redaction_key =
RedactionKey::resolve_for_share(options.redact_key, &bundle_profile)?;
let outputs = render_existing_run(RenderExistingArgs {
out: &options.out,
run: options.run,
latest: options.latest,
user: None,
window_label: None,
redaction_key,
bundle_profile,
mode: RenderPacketMode::Packet,
receipt_limit: None,
appendix: None,
zip: options.zip,
})?;
print_share_outputs(&outputs, &BundleProfile::Manager);
}
ShareCommand::Public(options) => {
let bundle_profile = BundleProfile::Public;
let redaction_key =
RedactionKey::resolve_for_share(options.redact_key, &bundle_profile)?;
let outputs = render_existing_run(RenderExistingArgs {
out: &options.out,
run: options.run,
latest: options.latest,
user: None,
window_label: None,
redaction_key,
bundle_profile,
mode: RenderPacketMode::Packet,
receipt_limit: None,
appendix: None,
zip: options.zip,
})?;
print_share_outputs(&outputs, &BundleProfile::Public);
}
ShareCommand::Verify { cmd } => match cmd {
ShareVerifyCommand::Manager(options) => {
verify_share_profile(options, BundleProfile::Manager)?;
}
ShareVerifyCommand::Public(options) => {
verify_share_profile(options, BundleProfile::Public)?;
}
},
},
Command::Refresh {
source,
out,
run_dir: explicit_run_dir,
zip,
redact_key,
bundle_profile,
} => {
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let clusterer: Box<dyn shiplog_ports::WorkstreamClusterer> = Box::new(RepoClusterer);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
let run_dir = if let Some(rd) = explicit_run_dir {
if rd == Path::new("latest") {
find_most_recent_run(&out)?
} else {
rd
}
} else {
find_most_recent_run(&out)?
};
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
match source {
Source::Git {
repo,
window,
author,
include_merges,
} => {
let window = resolve_date_window(window)?;
let ing = make_git_ingestor(
&repo,
window.since,
window.until,
author,
include_merges,
);
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let outputs = engine
.refresh(
ingest,
"local",
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Github {
user,
me,
window,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitHub", user, me, || {
discover_github_user(&api_base, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_root = run_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| out.clone());
let cache_dir = resolve_cache_dir(&cache_root, cache_dir, no_cache);
let ing = make_github_ingestor(
&user,
window.since,
window.until,
&mode,
include_reviews,
no_details,
throttle_ms,
token,
&api_base,
cache_dir,
)
.context("create GitHub ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let outputs = engine
.refresh(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Gitlab {
user,
me,
window,
state,
instance,
include_reviews,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitLab", user, me, || {
discover_gitlab_user(&instance, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_root = run_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| out.clone());
let cache_dir = resolve_cache_dir(&cache_root, cache_dir, no_cache);
let ing = make_gitlab_ingestor(
&user,
window.since,
window.until,
&state,
&instance,
include_reviews,
throttle_ms,
token,
cache_dir,
)
.context("create GitLab ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let outputs = engine
.refresh(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Jira {
user,
auth_user,
window,
status,
instance,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_root = run_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| out.clone());
let cache_dir = resolve_cache_dir(&cache_root, cache_dir, no_cache);
let ing = make_jira_ingestor(
&user,
auth_user,
window.since,
window.until,
&status,
&instance,
throttle_ms,
token,
cache_dir,
)
.context("create Jira ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let outputs = engine
.refresh(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Linear {
user_id,
window,
status,
project,
throttle_ms,
api_key,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_root = run_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| out.clone());
let cache_dir = resolve_cache_dir(&cache_root, cache_dir, no_cache);
let ing = make_linear_ingestor(
&user_id,
window.since,
window.until,
&status,
project,
throttle_ms,
api_key,
cache_dir,
)
.context("create Linear ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let outputs = engine
.refresh(
ingest,
&user_id,
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Json {
events,
coverage,
user,
window_label,
} => {
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let ing = JsonIngestor {
events_path: events,
coverage_path: coverage,
};
let ingest = ing.ingest().context("ingest events")?;
let outputs = engine
.refresh(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
Source::Manual {
events,
user,
window,
} => {
let window = resolve_date_window(window)?;
if !shiplog_workstreams::WorkstreamManager::has_curated(&run_dir)
&& !shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir)
.exists()
{
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let ing =
ManualIngestor::new(&events, user.clone(), window.since, window.until);
let ingest = ing.ingest().context("ingest events")?;
let window_label = window.window_label();
let outputs = engine
.refresh(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("refresh engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Refreshed while preserving workstream curation:");
print_outputs_simple(&outputs);
}
}
}
Command::Workstreams { cmd } => match cmd {
WorkstreamsCommand::List { out, run, latest } => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (workstreams, source, path) = load_effective_workstreams_for_run(&run_dir)?;
print_workstreams_list(&run_dir, &path, source, &workstreams);
}
WorkstreamsCommand::Validate { out, run, latest } => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (workstreams, source, path) = load_effective_workstreams_for_run(&run_dir)?;
let errors = validate_workstreams_for_run(&run_dir, &workstreams)?;
if errors.is_empty() {
println!(
"Workstreams valid: {} ({})",
path.display(),
workstream_source_label(source)
);
println!("- {} workstreams", workstreams.workstreams.len());
println!(
"- {} assigned events",
workstreams
.workstreams
.iter()
.map(|workstream| workstream.events.len())
.sum::<usize>()
);
println!(
"- {} receipts",
workstreams
.workstreams
.iter()
.map(|workstream| workstream.receipts.len())
.sum::<usize>()
);
} else {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
}
WorkstreamsCommand::Rename {
out,
run,
latest,
from,
to,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) = load_effective_workstreams_for_run(&run_dir)?;
let old_title = rename_workstream(&mut workstreams, &from, &to)?;
write_curated_workstreams(&run_dir, &workstreams)?;
println!("Renamed workstream: {old_title} -> {}", to.trim());
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
WorkstreamsCommand::Move {
out,
run,
latest,
event,
to,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) = load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
let result =
move_event_to_workstream(&mut workstreams, &event, &to, &ledger_events)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!("Moved event {} to {}", result.event_id, result.to_title);
if result.from_titles.is_empty() {
println!("Source: unassigned");
} else {
println!("Source: {}", result.from_titles.join(", "));
}
if result.receipt_preserved {
println!("Receipt anchor preserved in target workstream.");
}
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
WorkstreamsCommand::Receipts {
out,
run,
latest,
workstream,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (workstreams, source, path) = load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
print_workstream_receipts(
&run_dir,
&path,
source,
&workstreams,
&ledger_events,
&workstream,
)?;
}
WorkstreamsCommand::Receipt { cmd } => match cmd {
WorkstreamReceiptCommand::Add {
out,
run,
latest,
workstream,
event,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) =
load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
let result = add_workstream_receipt(
&mut workstreams,
&workstream,
&event,
&ledger_events,
)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!(
"Added receipt anchor {} to {}",
result.event_id, result.workstream_title
);
println!("Receipt: {}", result.event_title);
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
WorkstreamReceiptCommand::Remove {
out,
run,
latest,
workstream,
event,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) =
load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
let result = remove_workstream_receipt(
&mut workstreams,
&workstream,
&event,
&ledger_events,
)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!(
"Removed receipt anchor {} from {}",
result.event_id, result.workstream_title
);
println!("Receipt: {}", result.event_title);
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
},
WorkstreamsCommand::Create {
out,
run,
latest,
title,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) = load_effective_workstreams_for_run(&run_dir)?;
let result = create_workstream(&mut workstreams, &title)?;
let ledger_events = load_run_events(&run_dir)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!("Created workstream: {}", result.title);
println!("ID: {}", result.id);
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
WorkstreamsCommand::Delete {
out,
run,
latest,
workstream,
move_to,
force,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) = load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
let result = delete_workstream(
&mut workstreams,
&workstream,
move_to.as_deref(),
force,
&ledger_events,
)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!("Deleted workstream: {}", result.deleted_title);
if let Some(target) = result.moved_to_title {
println!(
"Moved {} event(s) and {} receipt anchor(s) to {}.",
result.event_count, result.receipt_count, target
);
} else if result.event_count > 0 || result.receipt_count > 0 {
println!(
"Discarded {} event assignment(s) and {} receipt anchor(s).",
result.event_count, result.receipt_count
);
}
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
WorkstreamsCommand::Split {
out,
run,
latest,
from,
to,
matching,
create,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (mut workstreams, source, _) = load_effective_workstreams_for_run(&run_dir)?;
let ledger_events = load_run_events(&run_dir)?;
let result = split_workstream(
&mut workstreams,
&from,
&to,
&matching,
create,
&ledger_events,
)?;
let errors = validate_workstreams_against_events(&workstreams, &ledger_events);
if !errors.is_empty() {
for error in &errors {
eprintln!("- {error}");
}
anyhow::bail!("{} workstream validation error(s)", errors.len());
}
write_curated_workstreams(&run_dir, &workstreams)?;
println!(
"Split {} event(s) from {} to {}",
result.event_count, result.from_title, result.to_title
);
println!("Matched: {}", result.pattern);
if result.receipt_count > 0 {
println!("Moved {} receipt anchor(s).", result.receipt_count);
}
if result.created_target {
println!("Created target workstream: {}", result.to_title);
}
println!(
"Updated: {}",
shiplog_workstreams::WorkstreamManager::curated_path(&run_dir).display()
);
if matches!(source, WorkstreamsFileSource::Suggested) {
println!("Created curated workstreams.yaml from suggested workstreams.");
}
}
},
Command::Runs { cmd } => match cmd {
RunsCommand::List { out } => {
let summaries = load_run_summaries(&out)?;
print_runs_list(&out, &summaries);
}
RunsCommand::Show { out, run, latest } => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let summary = load_run_summary(&run_dir)?;
print_run_show(&summary);
}
RunsCommand::Compare { out, from, to } => {
let from_dir = resolve_run_selector(&out, &from)?;
let to_dir = resolve_run_selector(&out, &to)?;
let comparison = compare_runs(&from_dir, &to_dir)?;
print_run_compare(&comparison);
}
},
Command::Review { cmd, options } => match cmd {
Some(ReviewCommand::Weekly {
out,
run,
latest,
strict,
}) => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
print_weekly_review(&run_dir, strict)?;
}
Some(ReviewCommand::Fixups {
out,
run,
latest,
commands_only,
journal_template,
}) => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
print_review_fixups(&run_dir, &out, commands_only, journal_template)?;
}
None => {
let run_dir = resolve_review_run_dir(
&options.out,
options.run,
options.latest,
&options.config,
options.period,
)?;
print_review(&run_dir, options.strict)?;
}
},
Command::Open { cmd } => match cmd {
OpenCommand::Packet {
out,
run,
latest,
print_path,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let packet = run_dir.join("packet.md");
open_existing_path(
&packet,
"Packet",
"Run `shiplog render --latest` to create it.",
print_path,
)?;
}
OpenCommand::Workstreams {
out,
run,
latest,
print_path,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let (_, _, path) = load_effective_workstreams_for_run(&run_dir)?;
open_existing_path(
&path,
"Workstreams file",
"Run `shiplog collect` first.",
print_path,
)?;
}
OpenCommand::Out {
out,
run,
latest,
print_path,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
open_existing_path(
&run_dir,
"Run directory",
"Run `shiplog collect` first.",
print_path,
)?;
}
OpenCommand::IntakeReport {
out,
run,
latest,
print_path,
} => {
let run_dir = resolve_render_run_dir(&out, run, latest)?;
let report = run_dir.join("intake.report.md");
open_existing_path(
&report,
"Intake report",
"Run `shiplog intake` first.",
print_path,
)?;
}
},
Command::Merge {
inputs,
out,
conflict,
user,
window_label,
zip,
redact_key,
bundle_profile,
regen,
} => {
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let clusterer: Box<dyn shiplog_ports::WorkstreamClusterer> = Box::new(RepoClusterer);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
let mut ingest_outputs = Vec::with_capacity(inputs.len());
for input in &inputs {
ingest_outputs.push(
load_run_ingest(input)
.with_context(|| format!("load merge input {}", input.display()))?,
);
}
let merged = engine
.merge(ingest_outputs, conflict.into())
.context("merge input runs")?;
let merge_user = user.unwrap_or_else(|| merged.coverage.user.clone());
let merge_window_label = window_label.unwrap_or_else(|| {
format!(
"{}..{}",
merged.coverage.window.since, merged.coverage.window.until
)
});
let run_id = merged.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
if regen {
let suggested = shiplog_workstreams::WorkstreamManager::suggested_path(&run_dir);
if suggested.exists() {
std::fs::remove_file(&suggested)
.with_context(|| format!("remove {:?} for --regen", suggested))?;
}
}
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(
merged,
&merge_user,
&merge_window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("run merged engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Merged and wrote:");
println!("- inputs: {}", inputs.len());
println!("- conflict: {}", conflict.as_str());
print_outputs(&outputs, ws_source);
}
Command::Import {
dir,
out,
user,
window_label,
redact_key,
bundle_profile,
zip,
regen,
llm_cluster,
llm_api_endpoint,
llm_model,
llm_api_key,
} => {
let events_path = dir.join("ledger.events.jsonl");
let coverage_path = dir.join("coverage.manifest.json");
if !events_path.exists() {
anyhow::bail!(
"No ledger.events.jsonl found in {:?}. Expected import directory.",
dir
);
}
if !coverage_path.exists() {
anyhow::bail!(
"No coverage.manifest.json found in {:?}. Expected import directory.",
dir
);
}
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
let ing = JsonIngestor {
events_path,
coverage_path,
};
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
if regen {
let curated = run_dir.join("workstreams.yaml");
let suggested = run_dir.join("workstreams.suggested.yaml");
let _ = std::fs::remove_file(&curated);
let _ = std::fs::remove_file(&suggested);
}
let workstreams = if regen {
None
} else {
shiplog_workstreams::WorkstreamManager::try_load(&dir)
.context("load workstreams from import directory")?
};
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.import(
ingest,
&user,
&window_label,
&run_dir,
zip,
workstreams,
&bundle_profile,
)
.context("import engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Imported and wrote:");
print_outputs(&outputs, ws_source);
}
Command::Run {
source,
out,
zip,
redact_key,
bundle_profile,
llm_cluster,
llm_api_endpoint,
llm_model,
llm_api_key,
} => {
let redaction_key = RedactionKey::resolve(redact_key, &bundle_profile)?;
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) =
create_engine(redaction_key.engine_key(), clusterer, &bundle_profile);
let engine = engine.with_profile_rendering(redaction_key.render_profiles());
match source {
Source::Git {
repo,
window,
author,
include_merges,
} => {
let window = resolve_date_window(window)?;
let ing = make_git_ingestor(
&repo,
window.since,
window.until,
author,
include_merges,
);
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let window_label = window.window_label();
let (outputs, ws_source) = engine
.run(
ingest,
"local",
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Github {
user,
me,
window,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitHub", user, me, || {
discover_github_user(&api_base, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_github_ingestor(
&user,
window.since,
window.until,
&mode,
include_reviews,
no_details,
throttle_ms,
token,
&api_base,
cache_dir,
)
.context("create GitHub ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let window_label = window.window_label();
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Gitlab {
user,
me,
window,
state,
instance,
include_reviews,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let user = resolve_user_or_me("GitLab", user, me, || {
discover_gitlab_user(&instance, token.as_deref())
})?;
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_gitlab_ingestor(
&user,
window.since,
window.until,
&state,
&instance,
include_reviews,
throttle_ms,
token,
cache_dir,
)
.context("create GitLab ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let window_label = window.window_label();
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Jira {
user,
auth_user,
window,
status,
instance,
throttle_ms,
token,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_jira_ingestor(
&user,
auth_user,
window.since,
window.until,
&status,
&instance,
throttle_ms,
token,
cache_dir,
)
.context("create Jira ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let window_label = window.window_label();
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Linear {
user_id,
window,
status,
project,
throttle_ms,
api_key,
cache_dir,
no_cache,
} => {
let window = resolve_date_window(window)?;
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_linear_ingestor(
&user_id,
window.since,
window.until,
&status,
project,
throttle_ms,
api_key,
cache_dir,
)
.context("create Linear ingestor")?;
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let window_label = window.window_label();
let (outputs, ws_source) = engine
.run(
ingest,
&user_id,
&window_label,
&run_dir,
zip,
&bundle_profile,
)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Json {
events,
coverage,
user,
window_label,
} => {
let ing = JsonIngestor {
events_path: events,
coverage_path: coverage,
};
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
Source::Manual {
events,
user,
window,
} => {
let window = resolve_date_window(window)?;
let ing =
ManualIngestor::new(&events, user.clone(), window.since, window.until);
let ingest = ing.ingest().context("ingest events")?;
let run_id = ingest.coverage.run_id.to_string();
let run_dir = out.join(&run_id);
let window_label = window.window_label();
let cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
let (outputs, ws_source) = engine
.run(ingest, &user, &window_label, &run_dir, zip, &bundle_profile)
.context("run engine pipeline")?;
redactor
.save_cache(&cache_path)
.with_context(|| format!("save redaction cache to {cache_path:?}"))?;
println!("Wrote:");
print_outputs(&outputs, ws_source);
}
}
}
}
Ok(())
}
fn event_count_phrase(count: usize) -> String {
let noun = if count == 1 { "event" } else { "events" };
format!("{count} {noun}")
}
fn print_outputs(outputs: &shiplog_engine::RunOutputs, ws_source: WorkstreamSource) {
println!(
"- {} ({})",
outputs.packet_md.display(),
match ws_source {
WorkstreamSource::Curated => "using your curated workstreams.yaml",
WorkstreamSource::Suggested =>
"using suggested workstreams (edit and rename to workstreams.yaml)",
WorkstreamSource::Generated => "newly generated",
}
);
println!("- {}", outputs.workstreams_yaml.display());
println!("- {}", outputs.ledger_events_jsonl.display());
println!("- {}", outputs.coverage_manifest_json.display());
println!("- {}", outputs.bundle_manifest_json.display());
if let Some(ref z) = outputs.zip_path {
println!("- {}", z.display());
}
}
fn print_outputs_simple(outputs: &shiplog_engine::RunOutputs) {
println!("- {}", outputs.packet_md.display());
println!("- {}", outputs.workstreams_yaml.display());
println!("- {}", outputs.ledger_events_jsonl.display());
println!("- {}", outputs.coverage_manifest_json.display());
println!("- {}", outputs.bundle_manifest_json.display());
if let Some(ref z) = outputs.zip_path {
println!("- {}", z.display());
}
}
fn print_share_outputs(outputs: &shiplog_engine::RunOutputs, bundle_profile: &BundleProfile) {
let profile_packet = outputs
.out_dir
.join("profiles")
.join(bundle_profile.as_str())
.join("packet.md");
println!("Wrote {bundle_profile} share output:");
println!("- {}", profile_packet.display());
println!("- {}", outputs.coverage_manifest_json.display());
println!("- {}", outputs.bundle_manifest_json.display());
if let Some(ref z) = outputs.zip_path {
println!("- {}", z.display());
}
}
fn verify_share_profile(options: ShareVerifyOptions, bundle_profile: BundleProfile) -> Result<()> {
let redaction_key = RedactionKey::resolve_for_share(options.redact_key, &bundle_profile)?;
let run_dir = resolve_render_run_dir(&options.out, options.run, options.latest)?;
let ingest =
load_run_ingest(&run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let coverage = ingest.coverage;
let events = ingest.events;
let (workstreams, ws_source, ws_path) = load_effective_workstreams_for_run(&run_dir)?;
let validation_errors = validate_workstreams_against_events(&workstreams, &events);
let skipped_sources = configured_source_skips(&coverage.warnings);
let gap_count = coverage_gap_count(&coverage);
let profile_packet = run_dir
.join("profiles")
.join(bundle_profile.as_str())
.join("packet.md");
let strict_public_scan = if matches!(bundle_profile, BundleProfile::Public) && options.strict {
Some(strict_public_packet_scan(
&run_dir,
&coverage,
&events,
&workstreams,
&redaction_key,
&profile_packet,
)?)
} else {
None
};
let out_arg = quote_cli_value(&options.out.display().to_string());
let run_arg = quote_cli_value(&coverage.run_id.to_string());
let mut good = Vec::new();
good.push("Redaction key found".to_string());
good.push(format!(
"Coverage manifest exists with {} status",
coverage.completeness
));
good.push(format!(
"Workstreams load from {}",
workstream_source_label(ws_source)
));
if profile_packet.exists() {
good.push(format!(
"Profile packet exists at {}",
profile_packet.display()
));
} else if validation_errors.is_empty() {
good.push("Profile packet can be rendered by the share command".to_string());
}
if let Some(scan) = &strict_public_scan
&& scan.findings.is_empty()
{
good.push(format!(
"Strict public scan checked {} and found no obvious raw private URLs/names.",
scan.source_label
));
}
let mut attention = Vec::new();
if gap_count > 0 {
attention.push(format!("{gap_count} coverage gap(s) should be reviewed."));
}
for source in &skipped_sources {
attention.push(format!(
"{} skipped: {}",
display_source_label(&source.source),
source.reason
));
}
for error in &validation_errors {
attention.push(format!("Workstream issue: {error}"));
}
if let Some(scan) = &strict_public_scan {
for finding in &scan.findings {
attention.push(format!("Strict public scan: {finding}"));
}
}
println!("Share verify: {bundle_profile}");
println!("Run: {}", coverage.run_id);
println!("Directory: {}", run_dir.display());
println!("Workstreams: {}", ws_path.display());
println!();
println!("Good:");
for item in &good {
println!("- {item}");
}
println!();
println!("Needs attention:");
if attention.is_empty() {
println!("- None");
} else {
for item in &attention {
println!("- {item}");
}
}
println!();
println!("Profile:");
if profile_packet.exists() {
println!("- Packet: {}", profile_packet.display());
} else {
println!("- Packet: not written yet; share can render it.");
}
println!("Coverage:");
println!("- Status: {}", coverage.completeness);
println!("- Gaps: {gap_count}");
println!("Skipped sources:");
if skipped_sources.is_empty() {
println!("- None");
} else {
for source in &skipped_sources {
println!(
"- {}: {}",
display_source_label(&source.source),
source.reason
);
}
}
println!();
println!("Share safety:");
println!("- Coverage and skipped-source warnings are present in the run metadata.");
match bundle_profile {
BundleProfile::Manager => {
println!("- Manager profile will use deterministic redaction aliases.");
}
BundleProfile::Public => {
println!("- Public profile will use the strictest redaction profile.");
println!("- Review the rendered packet before sharing outside your organization.");
if let Some(scan) = &strict_public_scan {
println!(
"- Strict public scan inspected {} for obvious raw URLs and original names.",
scan.source_label
);
println!("- Strict scan is a guardrail, not a guarantee of perfect privacy.");
}
}
BundleProfile::Internal => {}
}
if attention.is_empty() {
println!("Result: ready to render {bundle_profile} share output.");
} else {
println!("Result: review attention items before sharing {bundle_profile} output.");
}
println!("Next:");
if !attention.is_empty() {
println!("1. shiplog review fixups --out {out_arg} --run {run_arg} --commands-only");
println!("2. shiplog share {bundle_profile} --out {out_arg} --run {run_arg}");
} else {
println!("1. shiplog share {bundle_profile} --out {out_arg} --run {run_arg}");
}
if let Some(scan) = &strict_public_scan
&& !scan.findings.is_empty()
{
anyhow::bail!(
"strict public verification found {} issue(s); inspect the public packet before sharing",
scan.findings.len()
);
}
Ok(())
}
struct StrictPublicPacketScan {
source_label: String,
findings: Vec<String>,
}
fn strict_public_packet_scan(
run_dir: &Path,
coverage: &CoverageManifest,
events: &[EventEnvelope],
workstreams: &WorkstreamsFile,
redaction_key: &RedactionKey,
profile_packet: &Path,
) -> Result<StrictPublicPacketScan> {
let (source_label, packet) = if profile_packet.exists() {
(
profile_packet.display().to_string(),
std::fs::read_to_string(profile_packet)
.with_context(|| format!("read public packet {}", profile_packet.display()))?,
)
} else {
let redactor = DeterministicRedactor::new(redaction_key.engine_key().as_bytes());
let cache_path = DeterministicRedactor::cache_path(run_dir);
let _ = redactor.load_cache(&cache_path);
let public_events = redactor
.redact_events(events, BundleProfile::Public.as_str())
.context("redact events for strict public verification")?;
let public_workstreams = redactor
.redact_workstreams(workstreams, BundleProfile::Public.as_str())
.context("redact workstreams for strict public verification")?;
let renderer = cli_packet_renderer();
let window_label = format!("{}..{}", coverage.window.since, coverage.window.until);
(
"in-memory public render".to_string(),
renderer
.render_packet_markdown_with_options(
&coverage.user,
&window_label,
&public_events,
&public_workstreams,
coverage,
cli_render_options(
RenderPacketMode::Packet,
None,
None,
&BundleProfile::Public,
),
)
.context("render public packet for strict verification")?,
)
};
Ok(StrictPublicPacketScan {
source_label,
findings: public_packet_privacy_findings(&packet, events, workstreams),
})
}
fn public_packet_privacy_findings(
packet: &str,
events: &[EventEnvelope],
workstreams: &WorkstreamsFile,
) -> Vec<String> {
let mut findings = BTreeSet::new();
let url_re = Regex::new(r"https?://").expect("valid strict public URL regex");
if url_re.is_match(packet) {
findings.insert("public packet contains raw URL(s).".to_string());
}
for event in events {
if packet_contains_sensitive_literal(packet, &event.repo.full_name) {
findings.insert("public packet contains an original repository name.".to_string());
}
if let Some(url) = &event.repo.html_url
&& packet_contains_sensitive_literal(packet, url)
{
findings.insert("public packet contains an original repository URL.".to_string());
}
if let Some(url) = &event.source.url
&& packet_contains_sensitive_literal(packet, url)
{
findings.insert("public packet contains an original source URL.".to_string());
}
for link in &event.links {
if packet_contains_sensitive_literal(packet, &link.url) {
findings.insert("public packet contains an original receipt URL.".to_string());
}
}
match &event.payload {
EventPayload::PullRequest(pr) => {
if packet_contains_sensitive_literal(packet, &pr.title) {
findings.insert("public packet contains an original PR title.".to_string());
}
}
EventPayload::Review(review) => {
if packet_contains_sensitive_literal(packet, &review.pull_title) {
findings.insert("public packet contains an original review title.".to_string());
}
}
EventPayload::Manual(manual) => {
if packet_contains_sensitive_literal(packet, &manual.title) {
findings.insert("public packet contains an original manual title.".to_string());
}
if let Some(description) = &manual.description
&& packet_contains_sensitive_literal(packet, description)
{
findings.insert(
"public packet contains an original manual description.".to_string(),
);
}
if let Some(impact) = &manual.impact
&& packet_contains_sensitive_literal(packet, impact)
{
findings
.insert("public packet contains an original manual impact.".to_string());
}
}
}
}
for workstream in &workstreams.workstreams {
if packet_contains_sensitive_literal(packet, &workstream.title) {
findings.insert("public packet contains an original workstream title.".to_string());
}
if let Some(summary) = &workstream.summary
&& packet_contains_sensitive_literal(packet, summary)
{
findings.insert("public packet contains an original workstream summary.".to_string());
}
}
findings.into_iter().collect()
}
fn packet_contains_sensitive_literal(packet: &str, value: &str) -> bool {
let value = value.trim();
value.len() >= 6 && value != "[redacted]" && packet.contains(value)
}
#[derive(Clone, Copy, Debug)]
enum WorkstreamsFileSource {
Curated,
Suggested,
}
fn load_effective_workstreams_for_run(
run_dir: &Path,
) -> Result<(WorkstreamsFile, WorkstreamsFileSource, PathBuf)> {
let curated = shiplog_workstreams::WorkstreamManager::curated_path(run_dir);
if curated.exists() {
let workstreams = shiplog_workstreams::WorkstreamManager::try_load(run_dir)?
.ok_or_else(|| anyhow::anyhow!("curated workstreams disappeared from {run_dir:?}"))?;
return Ok((workstreams, WorkstreamsFileSource::Curated, curated));
}
let suggested = shiplog_workstreams::WorkstreamManager::suggested_path(run_dir);
if suggested.exists() {
let workstreams = shiplog_workstreams::WorkstreamManager::try_load(run_dir)?
.ok_or_else(|| anyhow::anyhow!("suggested workstreams disappeared from {run_dir:?}"))?;
return Ok((workstreams, WorkstreamsFileSource::Suggested, suggested));
}
anyhow::bail!(
"No workstreams found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
fn workstream_source_label(source: WorkstreamsFileSource) -> &'static str {
match source {
WorkstreamsFileSource::Curated => "curated workstreams.yaml",
WorkstreamsFileSource::Suggested => "suggested workstreams.suggested.yaml",
}
}
fn print_workstreams_list(
run_dir: &Path,
path: &Path,
source: WorkstreamsFileSource,
workstreams: &WorkstreamsFile,
) {
println!(
"Workstreams: {} ({})",
path.display(),
workstream_source_label(source)
);
println!("Run: {}", run_dir.display());
println!("Count: {}", workstreams.workstreams.len());
for workstream in &workstreams.workstreams {
let tags = if workstream.tags.is_empty() {
"-".to_string()
} else {
workstream.tags.join(",")
};
println!("- {} [{}]", workstream.title, workstream.id);
println!(
" events={} receipts={} prs={} reviews={} manual={} tags={}",
workstream.events.len(),
workstream.receipts.len(),
workstream.stats.pull_requests,
workstream.stats.reviews,
workstream.stats.manual_events,
tags
);
}
}
fn rename_workstream(workstreams: &mut WorkstreamsFile, from: &str, to: &str) -> Result<String> {
let new_title = to.trim();
if new_title.is_empty() {
anyhow::bail!("new workstream title cannot be blank");
}
let idx = find_workstream_index(workstreams, from)?;
if workstreams
.workstreams
.iter()
.enumerate()
.any(|(other_idx, workstream)| other_idx != idx && workstream.title == new_title)
{
anyhow::bail!("another workstream is already titled {new_title:?}");
}
let old_title = workstreams.workstreams[idx].title.clone();
workstreams.workstreams[idx].title = new_title.to_string();
Ok(old_title)
}
struct MoveWorkstreamResult {
event_id: String,
from_titles: Vec<String>,
to_title: String,
receipt_preserved: bool,
}
struct ReceiptEditResult {
event_id: String,
event_title: String,
workstream_title: String,
}
struct CreateWorkstreamResult {
id: WorkstreamId,
title: String,
}
struct DeleteWorkstreamResult {
deleted_title: String,
moved_to_title: Option<String>,
event_count: usize,
receipt_count: usize,
}
struct SplitWorkstreamResult {
event_count: usize,
receipt_count: usize,
from_title: String,
to_title: String,
pattern: String,
created_target: bool,
}
fn move_event_to_workstream(
workstreams: &mut WorkstreamsFile,
event_selector: &str,
target_selector: &str,
ledger_events: &[EventEnvelope],
) -> Result<MoveWorkstreamResult> {
let target_idx = find_workstream_index(workstreams, target_selector)?;
let event = ledger_events
.iter()
.find(|event| event.id.to_string() == event_selector)
.ok_or_else(|| {
anyhow::anyhow!("event {event_selector:?} was not found in ledger.events.jsonl")
})?;
let event_id = event.id.clone();
let event_key = event.id.to_string();
let mut from_titles = Vec::new();
let mut receipt_preserved = false;
for (idx, workstream) in workstreams.workstreams.iter_mut().enumerate() {
let had_event = workstream
.events
.iter()
.any(|candidate| candidate.to_string() == event_key);
if had_event && idx != target_idx {
from_titles.push(workstream.title.clone());
}
workstream
.events
.retain(|candidate| candidate.to_string() != event_key);
let had_receipt = workstream
.receipts
.iter()
.any(|candidate| candidate.to_string() == event_key);
receipt_preserved |= had_receipt;
workstream
.receipts
.retain(|candidate| candidate.to_string() != event_key);
}
let target = &mut workstreams.workstreams[target_idx];
let to_title = target.title.clone();
target.events.push(event_id.clone());
if receipt_preserved {
target.receipts.push(event_id);
}
recompute_workstream_stats(workstreams, ledger_events);
Ok(MoveWorkstreamResult {
event_id: event_key,
from_titles,
to_title,
receipt_preserved,
})
}
fn print_workstream_receipts(
run_dir: &Path,
path: &Path,
source: WorkstreamsFileSource,
workstreams: &WorkstreamsFile,
ledger_events: &[EventEnvelope],
workstream_selector: &str,
) -> Result<()> {
let idx = find_workstream_index(workstreams, workstream_selector)?;
let workstream = &workstreams.workstreams[idx];
let by_id: HashMap<_, _> = ledger_events
.iter()
.map(|event| (event.id.to_string(), event))
.collect();
println!("Receipts: {} [{}]", workstream.title, workstream.id);
println!("Run: {}", run_dir.display());
println!(
"Workstreams: {} ({})",
path.display(),
workstream_source_label(source)
);
println!("Count: {}", workstream.receipts.len());
if workstream.receipts.is_empty() {
println!("- (none)");
return Ok(());
}
for receipt_id in &workstream.receipts {
if let Some(event) = by_id.get(&receipt_id.to_string()) {
println!("{}", format_receipt_markdown(event));
} else {
println!("- {} (missing from ledger.events.jsonl)", receipt_id);
}
}
Ok(())
}
fn add_workstream_receipt(
workstreams: &mut WorkstreamsFile,
workstream_selector: &str,
event_selector: &str,
ledger_events: &[EventEnvelope],
) -> Result<ReceiptEditResult> {
let idx = find_workstream_index(workstreams, workstream_selector)?;
let event = find_ledger_event(ledger_events, event_selector)?;
let event_id = event.id.clone();
let event_key = event.id.to_string();
let event_title = event_title(event).to_string();
let workstream = &mut workstreams.workstreams[idx];
if !contains_event_id(&workstream.events, &event_key) {
anyhow::bail!(
"event {event_key:?} is not assigned to workstream {:?}; use `shiplog workstreams move` first",
workstream.title
);
}
if contains_event_id(&workstream.receipts, &event_key) {
anyhow::bail!(
"event {event_key:?} is already a receipt anchor for workstream {:?}",
workstream.title
);
}
workstream.receipts.push(event_id);
Ok(ReceiptEditResult {
event_id: event_key,
event_title,
workstream_title: workstream.title.clone(),
})
}
fn remove_workstream_receipt(
workstreams: &mut WorkstreamsFile,
workstream_selector: &str,
event_selector: &str,
ledger_events: &[EventEnvelope],
) -> Result<ReceiptEditResult> {
let idx = find_workstream_index(workstreams, workstream_selector)?;
let event = find_ledger_event(ledger_events, event_selector)?;
let event_key = event.id.to_string();
let event_title = event_title(event).to_string();
let workstream = &mut workstreams.workstreams[idx];
let Some(receipt_idx) = workstream
.receipts
.iter()
.position(|candidate| candidate.to_string() == event_key)
else {
anyhow::bail!(
"event {event_key:?} is not a receipt anchor for workstream {:?}",
workstream.title
);
};
workstream.receipts.remove(receipt_idx);
Ok(ReceiptEditResult {
event_id: event_key,
event_title,
workstream_title: workstream.title.clone(),
})
}
fn create_workstream(
workstreams: &mut WorkstreamsFile,
title: &str,
) -> Result<CreateWorkstreamResult> {
let title = title.trim();
if title.is_empty() {
anyhow::bail!("workstream title cannot be blank");
}
if find_workstream_index_optional(workstreams, title)?.is_some() {
anyhow::bail!("a workstream already matches {title:?}");
}
let id = WorkstreamId::from_parts(["curated", title]);
workstreams.workstreams.push(Workstream {
id: id.clone(),
title: title.to_string(),
summary: None,
tags: vec![],
stats: WorkstreamStats::zero(),
events: vec![],
receipts: vec![],
});
Ok(CreateWorkstreamResult {
id,
title: title.to_string(),
})
}
fn delete_workstream(
workstreams: &mut WorkstreamsFile,
workstream_selector: &str,
move_to: Option<&str>,
force: bool,
ledger_events: &[EventEnvelope],
) -> Result<DeleteWorkstreamResult> {
if force && move_to.is_some() {
anyhow::bail!("use either --move-to or --force, not both");
}
let source_idx = find_workstream_index(workstreams, workstream_selector)?;
let event_count = workstreams.workstreams[source_idx].events.len();
let receipt_count = workstreams.workstreams[source_idx].receipts.len();
let is_non_empty = event_count > 0 || receipt_count > 0;
if is_non_empty && move_to.is_none() && !force {
anyhow::bail!(
"workstream {:?} is not empty; use --move-to <workstream> or --force",
workstreams.workstreams[source_idx].title
);
}
let mut moved_to_title = None;
if let Some(target_selector) = move_to {
let target_idx = find_workstream_index(workstreams, target_selector)?;
if source_idx == target_idx {
anyhow::bail!("delete target and --move-to target must be different workstreams");
}
let events = workstreams.workstreams[source_idx].events.clone();
let receipts = workstreams.workstreams[source_idx].receipts.clone();
let target = &mut workstreams.workstreams[target_idx];
moved_to_title = Some(target.title.clone());
append_unique_event_ids(&mut target.events, events);
append_unique_event_ids(&mut target.receipts, receipts);
}
let deleted = workstreams.workstreams.remove(source_idx);
recompute_workstream_stats(workstreams, ledger_events);
Ok(DeleteWorkstreamResult {
deleted_title: deleted.title,
moved_to_title,
event_count,
receipt_count,
})
}
fn append_unique_event_ids(
target: &mut Vec<shiplog_ids::EventId>,
incoming: Vec<shiplog_ids::EventId>,
) {
for event_id in incoming {
let event_key = event_id.to_string();
if !target
.iter()
.any(|candidate| candidate.to_string() == event_key)
{
target.push(event_id);
}
}
}
fn split_workstream(
workstreams: &mut WorkstreamsFile,
from_selector: &str,
target_selector: &str,
pattern: &str,
create: bool,
ledger_events: &[EventEnvelope],
) -> Result<SplitWorkstreamResult> {
let pattern = pattern.trim();
if pattern.is_empty() {
anyhow::bail!("split matching pattern cannot be blank");
}
let matcher = RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.with_context(|| format!("compile split matching regex {pattern:?}"))?;
let from_idx = find_workstream_index(workstreams, from_selector)?;
let from_title = workstreams.workstreams[from_idx].title.clone();
let (target_idx, created_target) =
resolve_split_target_index(workstreams, target_selector, create)?;
if from_idx == target_idx {
anyhow::bail!("split target must be different from source workstream");
}
let to_title = workstreams.workstreams[target_idx].title.clone();
let events_by_id: HashMap<_, _> = ledger_events
.iter()
.map(|event| (event.id.to_string(), event))
.collect();
let mut matched_ids = HashSet::new();
for event_id in workstreams.workstreams[from_idx].events.clone() {
let event_key = event_id.to_string();
let event = events_by_id.get(event_key.as_str()).ok_or_else(|| {
anyhow::anyhow!("event {event_key:?} was not found in ledger.events.jsonl")
})?;
if event_matches_split_pattern(event, &matcher) {
matched_ids.insert(event_key);
}
}
if matched_ids.is_empty() {
anyhow::bail!("no events in {from_title:?} matched {pattern:?}");
}
let mut moved_events = Vec::new();
let mut moved_receipts = Vec::new();
for workstream in &mut workstreams.workstreams {
workstream.events.retain(|event_id| {
let event_key = event_id.to_string();
if matched_ids.contains(&event_key) {
if !moved_events
.iter()
.any(|candidate: &shiplog_ids::EventId| candidate.to_string() == event_key)
{
moved_events.push(event_id.clone());
}
false
} else {
true
}
});
workstream.receipts.retain(|event_id| {
let event_key = event_id.to_string();
if matched_ids.contains(&event_key) {
if !moved_receipts
.iter()
.any(|candidate: &shiplog_ids::EventId| candidate.to_string() == event_key)
{
moved_receipts.push(event_id.clone());
}
false
} else {
true
}
});
}
let target = &mut workstreams.workstreams[target_idx];
target.events.extend(moved_events.iter().cloned());
target.receipts.extend(moved_receipts.iter().cloned());
recompute_workstream_stats(workstreams, ledger_events);
Ok(SplitWorkstreamResult {
event_count: moved_events.len(),
receipt_count: moved_receipts.len(),
from_title,
to_title,
pattern: pattern.to_string(),
created_target,
})
}
fn resolve_split_target_index(
workstreams: &mut WorkstreamsFile,
target_selector: &str,
create: bool,
) -> Result<(usize, bool)> {
let target_selector = target_selector.trim();
if target_selector.is_empty() {
anyhow::bail!("target workstream title or ID cannot be blank");
}
if let Some(idx) = find_workstream_index_optional(workstreams, target_selector)? {
return Ok((idx, false));
}
if !create {
anyhow::bail!("no workstream matched {target_selector:?}; add --create to create it");
}
let idx = workstreams.workstreams.len();
workstreams.workstreams.push(Workstream {
id: WorkstreamId::from_parts(["split", target_selector]),
title: target_selector.to_string(),
summary: None,
tags: vec![],
stats: WorkstreamStats::zero(),
events: vec![],
receipts: vec![],
});
Ok((idx, true))
}
fn event_matches_split_pattern(event: &EventEnvelope, matcher: &Regex) -> bool {
event_split_match_fields(event)
.into_iter()
.any(|field| matcher.is_match(&field))
}
fn event_split_match_fields(event: &EventEnvelope) -> Vec<String> {
let mut fields = Vec::new();
fields.push(event_title(event).to_string());
fields.push(event.repo.full_name.clone());
if let Some(url) = &event.repo.html_url {
fields.push(url.clone());
}
fields.extend(event.tags.iter().cloned());
if let Some(url) = &event.source.url {
fields.push(url.clone());
}
fields.extend(event.links.iter().map(|link| link.url.clone()));
fields
}
fn event_title(event: &EventEnvelope) -> &str {
match &event.payload {
EventPayload::PullRequest(payload) => &payload.title,
EventPayload::Review(payload) => &payload.pull_title,
EventPayload::Manual(payload) => &payload.title,
}
}
fn find_ledger_event<'a>(
ledger_events: &'a [EventEnvelope],
event_selector: &str,
) -> Result<&'a EventEnvelope> {
let event_selector = event_selector.trim();
if event_selector.is_empty() {
anyhow::bail!("event selector cannot be blank");
}
ledger_events
.iter()
.find(|event| event.id.to_string() == event_selector)
.ok_or_else(|| {
anyhow::anyhow!("event {event_selector:?} was not found in ledger.events.jsonl")
})
}
fn contains_event_id(event_ids: &[EventId], event_key: &str) -> bool {
event_ids
.iter()
.any(|candidate| candidate.to_string() == event_key)
}
fn find_workstream_index(workstreams: &WorkstreamsFile, selector: &str) -> Result<usize> {
let selector = selector.trim();
if selector.is_empty() {
anyhow::bail!("workstream selector cannot be blank");
}
match find_workstream_index_optional(workstreams, selector)? {
Some(idx) => Ok(idx),
None => anyhow::bail!(
"no workstream matched {selector:?}; run `shiplog workstreams list` to see available titles and IDs"
),
}
}
fn find_workstream_index_optional(
workstreams: &WorkstreamsFile,
selector: &str,
) -> Result<Option<usize>> {
let selector = selector.trim();
if selector.is_empty() {
anyhow::bail!("workstream selector cannot be blank");
}
let matches: Vec<_> = workstreams
.workstreams
.iter()
.enumerate()
.filter(|(_, workstream)| {
workstream.title == selector || workstream.id.to_string() == selector
})
.map(|(idx, _)| idx)
.collect();
match matches.as_slice() {
[idx] => Ok(Some(*idx)),
[] => Ok(None),
_ => anyhow::bail!(
"multiple workstreams matched {selector:?}; use the workstream ID instead"
),
}
}
fn recompute_workstream_stats(workstreams: &mut WorkstreamsFile, ledger_events: &[EventEnvelope]) {
let event_kinds: HashMap<_, _> = ledger_events
.iter()
.map(|event| (event.id.to_string(), event.kind.clone()))
.collect();
for workstream in &mut workstreams.workstreams {
workstream.stats = WorkstreamStats::zero();
let event_ids = workstream.events.clone();
for event_id in event_ids {
if let Some(kind) = event_kinds.get(&event_id.to_string()) {
workstream.bump_stats(kind);
}
}
}
}
fn write_curated_workstreams(run_dir: &Path, workstreams: &WorkstreamsFile) -> Result<()> {
let curated_path = shiplog_workstreams::WorkstreamManager::curated_path(run_dir);
shiplog_workstreams::write_workstreams(&curated_path, workstreams)
.with_context(|| format!("write curated workstreams to {curated_path:?}"))
}
fn validate_workstreams_for_run(
run_dir: &Path,
workstreams: &WorkstreamsFile,
) -> Result<Vec<String>> {
let ledger_events = load_run_events(run_dir)?;
Ok(validate_workstreams_against_events(
workstreams,
&ledger_events,
))
}
fn validate_workstreams_against_events(
workstreams: &WorkstreamsFile,
ledger_events: &[EventEnvelope],
) -> Vec<String> {
let ledger_ids: HashSet<_> = ledger_events
.iter()
.map(|event| event.id.to_string())
.collect();
let mut errors = Vec::new();
if workstreams.version != 1 {
errors.push(format!(
"unsupported workstreams version {}; expected 1",
workstreams.version
));
}
let mut workstream_ids = HashSet::new();
let mut assigned_events: HashMap<String, String> = HashMap::new();
for (idx, workstream) in workstreams.workstreams.iter().enumerate() {
let ordinal = idx + 1;
let title = if workstream.title.trim().is_empty() {
format!("#{ordinal}")
} else {
workstream.title.clone()
};
if !workstream_ids.insert(workstream.id.to_string()) {
errors.push(format!("duplicate workstream id {}", workstream.id));
}
if workstream.title.trim().is_empty() {
errors.push(format!("workstream {ordinal} has a blank title"));
}
let mut local_events = HashSet::new();
for event_id in &workstream.events {
let event_id = event_id.to_string();
if !local_events.insert(event_id.clone()) {
errors.push(format!(
"workstream {title} lists event {event_id} more than once"
));
continue;
}
if !ledger_ids.contains(&event_id) {
errors.push(format!(
"workstream {title} references event {event_id} not found in ledger.events.jsonl"
));
}
if let Some(previous) = assigned_events.insert(event_id.clone(), title.clone()) {
errors.push(format!(
"event {event_id} is assigned to both {previous} and {title}"
));
}
}
let mut local_receipts = HashSet::new();
for receipt_id in &workstream.receipts {
let receipt_id = receipt_id.to_string();
if !local_receipts.insert(receipt_id.clone()) {
errors.push(format!(
"workstream {title} lists receipt {receipt_id} more than once"
));
continue;
}
if !ledger_ids.contains(&receipt_id) {
errors.push(format!(
"workstream {title} references receipt {receipt_id} not found in ledger.events.jsonl"
));
}
if !local_events.contains(&receipt_id) {
errors.push(format!(
"workstream {title} receipt {receipt_id} is not listed in that workstream's events"
));
}
}
}
errors
}
fn load_run_events(run_dir: &Path) -> Result<Vec<EventEnvelope>> {
let ingest = load_run_ingest(run_dir).context("ingest run ledger for workstream validation")?;
Ok(ingest.events)
}
fn load_run_ingest(run_dir: &Path) -> Result<IngestOutput> {
let events_path = run_dir.join("ledger.events.jsonl");
if !events_path.exists() {
anyhow::bail!(
"No ledger.events.jsonl found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let coverage_path = run_dir.join("coverage.manifest.json");
if !coverage_path.exists() {
anyhow::bail!(
"No coverage.manifest.json found in {:?}. Run `shiplog collect` first.",
run_dir
);
}
let ingest = JsonIngestor {
events_path,
coverage_path,
}
.ingest()
.context("ingest run ledger")?;
Ok(ingest)
}
struct RunSummary {
run_dir: PathBuf,
run_id: String,
generated_at: String,
modified_at: String,
user: String,
window: String,
mode: String,
sources: Vec<String>,
event_count: usize,
completeness: String,
gap_count: usize,
warnings: Vec<String>,
packet_path: PathBuf,
ledger_path: PathBuf,
coverage_path: PathBuf,
}
struct RunCompareData {
summary: RunSummary,
events: Vec<EventEnvelope>,
workstreams: WorkstreamsFile,
}
struct RunComparison {
from: RunCompareData,
to: RunCompareData,
added_sources: Vec<String>,
removed_sources: Vec<String>,
continued_sources: Vec<String>,
added_workstreams: Vec<String>,
removed_workstreams: Vec<String>,
continued_workstreams: Vec<String>,
expanded_workstreams: Vec<(String, isize)>,
contracted_workstreams: Vec<(String, isize)>,
}
fn load_run_summaries(out_dir: &Path) -> Result<Vec<RunSummary>> {
discover_run_dirs(out_dir)?
.iter()
.map(|run_dir| load_run_summary(run_dir))
.collect()
}
fn load_run_summary(run_dir: &Path) -> Result<RunSummary> {
let ingest =
load_run_ingest(run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let coverage = ingest.coverage;
let run_id = coverage.run_id.to_string();
let generated_at = coverage.generated_at.to_rfc3339();
let modified_at = modified_time_label(run_dir);
let window = format!("{}..{}", coverage.window.since, coverage.window.until);
let gap_count = coverage_gap_count(&coverage);
Ok(RunSummary {
run_dir: run_dir.to_path_buf(),
run_id,
generated_at,
modified_at,
user: coverage.user,
window,
mode: coverage.mode,
sources: coverage.sources,
event_count: ingest.events.len(),
completeness: coverage.completeness.to_string(),
gap_count,
warnings: coverage.warnings,
packet_path: run_dir.join("packet.md"),
ledger_path: run_dir.join("ledger.events.jsonl"),
coverage_path: run_dir.join("coverage.manifest.json"),
})
}
fn discover_run_dirs(out_dir: &Path) -> Result<Vec<PathBuf>> {
if !out_dir.exists() {
anyhow::bail!("Output directory {:?} does not exist.", out_dir);
}
let mut runs: Vec<_> = std::fs::read_dir(out_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter(|entry| entry.path().join("ledger.events.jsonl").exists())
.collect();
runs.sort_by(|a, b| {
let a_meta = a.metadata().and_then(|meta| meta.modified()).ok();
let b_meta = b.metadata().and_then(|meta| meta.modified()).ok();
b_meta.cmp(&a_meta)
});
let runs: Vec<_> = runs.into_iter().map(|entry| entry.path()).collect();
if runs.is_empty() {
anyhow::bail!("No run directories found in {:?}", out_dir);
}
Ok(runs)
}
fn modified_time_label(path: &Path) -> String {
path.metadata()
.and_then(|meta| meta.modified())
.map(|time| DateTime::<Utc>::from(time).to_rfc3339())
.unwrap_or_else(|_| "unknown".to_string())
}
fn coverage_gap_count(coverage: &shiplog_schema::coverage::CoverageManifest) -> usize {
coverage.warnings.len()
+ coverage
.slices
.iter()
.filter(|slice| {
slice.incomplete_results.unwrap_or(false) || slice.fetched < slice.total_count
})
.count()
}
fn print_runs_list(out_dir: &Path, summaries: &[RunSummary]) {
println!("Runs: {}", out_dir.display());
println!("Count: {}", summaries.len());
for summary in summaries {
println!("- {}", summary.run_id);
println!(" modified: {}", summary.modified_at);
println!(" sources: {}", source_list_label(&summary.sources));
println!(" events: {}", summary.event_count);
println!(" coverage: {}", summary.completeness);
println!(" packet: {}", summary.packet_path.display());
println!(" gaps: {}", summary.gap_count);
}
}
fn print_run_show(summary: &RunSummary) {
println!("Run: {}", summary.run_id);
println!("Directory: {}", summary.run_dir.display());
println!("Created: {}", summary.generated_at);
println!("Modified: {}", summary.modified_at);
println!("User: {}", summary.user);
println!("Window: {}", summary.window);
println!("Mode: {}", summary.mode);
println!("Sources: {}", source_list_label(&summary.sources));
println!("Events: {}", summary.event_count);
println!("Coverage: {}", summary.completeness);
println!("Gaps: {}", summary.gap_count);
println!("Packet: {}", summary.packet_path.display());
println!("Ledger: {}", summary.ledger_path.display());
println!("Coverage manifest: {}", summary.coverage_path.display());
if summary.warnings.is_empty() {
println!("Warnings: none");
} else {
println!("Warnings:");
for warning in &summary.warnings {
println!("- {warning}");
}
}
}
fn resolve_run_selector(out_dir: &Path, selector: &str) -> Result<PathBuf> {
resolve_render_run_dir(out_dir, Some(selector.to_string()), false)
}
fn compare_runs(from_dir: &Path, to_dir: &Path) -> Result<RunComparison> {
let from = load_run_compare_data(from_dir)?;
let to = load_run_compare_data(to_dir)?;
let from_sources = source_set(&from.summary.sources, &from.events);
let to_sources = source_set(&to.summary.sources, &to.events);
let from_workstreams = workstream_event_counts(&from.workstreams);
let to_workstreams = workstream_event_counts(&to.workstreams);
let from_workstream_titles = from_workstreams.keys().cloned().collect::<BTreeSet<_>>();
let to_workstream_titles = to_workstreams.keys().cloned().collect::<BTreeSet<_>>();
let mut expanded_workstreams = Vec::new();
let mut contracted_workstreams = Vec::new();
for title in from_workstream_titles.intersection(&to_workstream_titles) {
let from_count = *from_workstreams.get(title).unwrap_or(&0);
let to_count = *to_workstreams.get(title).unwrap_or(&0);
let delta = to_count as isize - from_count as isize;
if delta > 0 {
expanded_workstreams.push((title.clone(), delta));
} else if delta < 0 {
contracted_workstreams.push((title.clone(), delta));
}
}
Ok(RunComparison {
from,
to,
added_sources: display_source_set_diff(&to_sources, &from_sources),
removed_sources: display_source_set_diff(&from_sources, &to_sources),
continued_sources: display_source_set_intersection(&from_sources, &to_sources),
added_workstreams: set_diff(&to_workstream_titles, &from_workstream_titles),
removed_workstreams: set_diff(&from_workstream_titles, &to_workstream_titles),
continued_workstreams: set_intersection(&from_workstream_titles, &to_workstream_titles),
expanded_workstreams,
contracted_workstreams,
})
}
fn load_run_compare_data(run_dir: &Path) -> Result<RunCompareData> {
let summary = load_run_summary(run_dir)?;
let ingest =
load_run_ingest(run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let workstreams = shiplog_workstreams::WorkstreamManager::try_load(run_dir)?
.unwrap_or_else(empty_workstreams_file);
Ok(RunCompareData {
summary,
events: ingest.events,
workstreams,
})
}
fn empty_workstreams_file() -> WorkstreamsFile {
WorkstreamsFile {
version: 1,
generated_at: Utc::now(),
workstreams: Vec::new(),
}
}
fn source_set(summary_sources: &[String], events: &[EventEnvelope]) -> BTreeSet<String> {
let mut sources = BTreeSet::new();
for source in summary_sources {
sources.insert(normalized_source_key(source));
}
for event in events {
sources.insert(normalized_source_key(event.source.system.as_str()));
}
sources
}
fn workstream_event_counts(workstreams: &WorkstreamsFile) -> BTreeMap<String, usize> {
workstreams
.workstreams
.iter()
.map(|workstream| (workstream.title.clone(), workstream.events.len()))
.collect()
}
fn display_source_set_diff(left: &BTreeSet<String>, right: &BTreeSet<String>) -> Vec<String> {
left.difference(right)
.map(|source| display_source_label(source))
.collect()
}
fn display_source_set_intersection(
left: &BTreeSet<String>,
right: &BTreeSet<String>,
) -> Vec<String> {
left.intersection(right)
.map(|source| display_source_label(source))
.collect()
}
fn set_diff(left: &BTreeSet<String>, right: &BTreeSet<String>) -> Vec<String> {
left.difference(right).cloned().collect()
}
fn set_intersection(left: &BTreeSet<String>, right: &BTreeSet<String>) -> Vec<String> {
left.intersection(right).cloned().collect()
}
fn print_run_compare(comparison: &RunComparison) {
let from = &comparison.from.summary;
let to = &comparison.to.summary;
let event_delta = to.event_count as isize - from.event_count as isize;
let gap_delta = to.gap_count as isize - from.gap_count as isize;
println!("Compare: {} -> {}", from.run_id, to.run_id);
println!("From: {}", from.run_dir.display());
println!("To: {}", to.run_dir.display());
println!();
println!("Events:");
println!("- from: {}", from.event_count);
println!("- to: {}", to.event_count);
println!("- delta: {}", signed_count(event_delta));
println!();
println!("Sources:");
print_named_list("Added", &comparison.added_sources);
print_named_list("Removed", &comparison.removed_sources);
print_named_list("Continued", &comparison.continued_sources);
println!();
println!("Workstreams:");
print_named_list("Added", &comparison.added_workstreams);
print_named_list("Removed", &comparison.removed_workstreams);
print_named_list("Continued", &comparison.continued_workstreams);
print_workstream_delta_list("Expanded", &comparison.expanded_workstreams);
print_workstream_delta_list("Contracted", &comparison.contracted_workstreams);
println!();
println!("Coverage:");
println!("- from: {}, gaps: {}", from.completeness, from.gap_count);
println!("- to: {}, gaps: {}", to.completeness, to.gap_count);
println!("- gap delta: {}", signed_count(gap_delta));
if from.completeness != to.completeness {
println!(
"- completeness changed: {} -> {}",
from.completeness, to.completeness
);
} else {
println!("- completeness changed: no");
}
println!();
println!("Next:");
println!("1. shiplog review --run {}", to.run_id);
println!("2. shiplog render --run {} --mode scaffold", to.run_id);
}
fn print_named_list(label: &str, values: &[String]) {
if values.is_empty() {
println!("- {label}: None");
} else {
println!("- {label}: {}", values.join(", "));
}
}
fn print_workstream_delta_list(label: &str, values: &[(String, isize)]) {
if values.is_empty() {
println!("- {label}: None");
return;
}
println!("- {label}:");
for (title, delta) in values {
println!(" - {title}: {} event(s)", signed_count(*delta));
}
}
fn signed_count(value: isize) -> String {
if value > 0 {
format!("+{value}")
} else {
value.to_string()
}
}
struct ConfiguredSourceSkip {
source: String,
reason: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum EvidenceDebtSeverity {
Info,
Warning,
Blocking,
}
impl EvidenceDebtSeverity {
fn label(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Blocking => "blocking",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum EvidenceDebtKind {
MissingSource,
PartialCoverage,
CoverageWarning,
IncompleteQuery,
ManualContext,
MissingReceiptAnchors,
ThinWorkstream,
LargeMiscWorkstream,
CodeOnlyWorkstream,
TicketOnlyWorkstream,
ManualOnlyWorkstream,
TooManySelectedReceipts,
BroadWorkstream,
WorkstreamValidation,
}
impl EvidenceDebtKind {
fn label(self) -> &'static str {
match self {
Self::MissingSource => "missing-source",
Self::PartialCoverage => "partial-coverage",
Self::CoverageWarning => "coverage-warning",
Self::IncompleteQuery => "incomplete-query",
Self::ManualContext => "manual-context",
Self::MissingReceiptAnchors => "no-selected-receipts",
Self::ThinWorkstream => "thin-workstream",
Self::LargeMiscWorkstream => "large-misc-workstream",
Self::CodeOnlyWorkstream => "code-only-workstream",
Self::TicketOnlyWorkstream => "ticket-only-workstream",
Self::ManualOnlyWorkstream => "manual-only-workstream",
Self::TooManySelectedReceipts => "too-many-selected-receipts",
Self::BroadWorkstream => "broad-workstream",
Self::WorkstreamValidation => "workstream-validation",
}
}
}
#[derive(Debug)]
struct EvidenceDebt {
severity: EvidenceDebtSeverity,
kind: EvidenceDebtKind,
summary: String,
detail: Option<String>,
next_step: Option<String>,
}
struct ReviewFixup {
title: String,
detail: Option<String>,
command: String,
}
#[derive(Default)]
struct WorkstreamQualitySignals<'a> {
no_receipt_workstreams: Vec<&'a Workstream>,
broad_workstreams: Vec<&'a Workstream>,
manual_context_workstreams: Vec<&'a Workstream>,
thin_workstreams: Vec<&'a Workstream>,
large_misc_workstreams: Vec<&'a Workstream>,
code_only_workstreams: Vec<&'a Workstream>,
ticket_only_workstreams: Vec<&'a Workstream>,
manual_only_workstreams: Vec<&'a Workstream>,
too_many_receipt_workstreams: Vec<&'a Workstream>,
manual_events: usize,
}
#[derive(Default)]
struct WorkstreamEvidenceProfile {
code: usize,
tickets: usize,
manual: usize,
}
struct EvidenceDebtInput<'a> {
run_id: &'a str,
coverage: &'a CoverageManifest,
events: &'a [EventEnvelope],
skipped_sources: &'a [ConfiguredSourceSkip],
workstreams: &'a WorkstreamsFile,
validation_errors: &'a [String],
signals: &'a WorkstreamQualitySignals<'a>,
}
impl EvidenceDebt {
fn new(
severity: EvidenceDebtSeverity,
kind: EvidenceDebtKind,
summary: impl Into<String>,
) -> Self {
Self {
severity,
kind,
summary: summary.into(),
detail: None,
next_step: None,
}
}
fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
fn next_step(mut self, next_step: impl Into<String>) -> Self {
self.next_step = Some(next_step.into());
self
}
}
const BROAD_WORKSTREAM_EVENT_THRESHOLD: usize = 10;
const LARGE_MISC_WORKSTREAM_EVENT_THRESHOLD: usize = 5;
const TOO_MANY_SELECTED_RECEIPTS_THRESHOLD: usize = 5;
const SINGLE_SOURCE_WORKSTREAM_EVENT_THRESHOLD: usize = 5;
fn workstream_quality_signals<'a>(
workstreams: &'a WorkstreamsFile,
events: &[EventEnvelope],
) -> WorkstreamQualitySignals<'a> {
let events_by_id: HashMap<EventId, &EventEnvelope> = events
.iter()
.map(|event| (event.id.clone(), event))
.collect();
let manual_events = events
.iter()
.filter(|event| matches!(event.payload, EventPayload::Manual(_)))
.count();
let mut signals = WorkstreamQualitySignals {
manual_events,
..WorkstreamQualitySignals::default()
};
for workstream in &workstreams.workstreams {
let event_count = workstream.events.len();
let profile = workstream_evidence_profile(workstream, &events_by_id);
if workstream.receipts.is_empty() {
signals.no_receipt_workstreams.push(workstream);
}
if event_count >= BROAD_WORKSTREAM_EVENT_THRESHOLD {
signals.broad_workstreams.push(workstream);
}
if event_count == 1 && workstream.receipts.is_empty() {
signals.thin_workstreams.push(workstream);
}
if event_count >= LARGE_MISC_WORKSTREAM_EVENT_THRESHOLD
&& is_misc_workstream_title(workstream)
{
signals.large_misc_workstreams.push(workstream);
}
if workstream.receipts.len() > TOO_MANY_SELECTED_RECEIPTS_THRESHOLD {
signals.too_many_receipt_workstreams.push(workstream);
}
if event_count >= SINGLE_SOURCE_WORKSTREAM_EVENT_THRESHOLD
&& profile.code > 0
&& profile.tickets == 0
&& profile.manual == 0
{
signals.code_only_workstreams.push(workstream);
}
if event_count >= SINGLE_SOURCE_WORKSTREAM_EVENT_THRESHOLD
&& profile.tickets > 0
&& profile.code == 0
&& profile.manual == 0
{
signals.ticket_only_workstreams.push(workstream);
}
if event_count >= 1 && profile.manual > 0 && profile.code == 0 && profile.tickets == 0 {
signals.manual_only_workstreams.push(workstream);
}
if event_count >= BROAD_WORKSTREAM_EVENT_THRESHOLD && profile.manual == 0 {
signals.manual_context_workstreams.push(workstream);
}
}
signals
}
fn workstream_evidence_profile(
workstream: &Workstream,
events_by_id: &HashMap<EventId, &EventEnvelope>,
) -> WorkstreamEvidenceProfile {
let mut profile = WorkstreamEvidenceProfile::default();
for event in workstream
.events
.iter()
.filter_map(|event_id| events_by_id.get(event_id).copied())
{
match event_source_bucket(event) {
WorkstreamSourceBucket::Code => profile.code += 1,
WorkstreamSourceBucket::Ticket => profile.tickets += 1,
WorkstreamSourceBucket::Manual => profile.manual += 1,
}
}
profile
}
enum WorkstreamSourceBucket {
Code,
Ticket,
Manual,
}
fn event_source_bucket(event: &EventEnvelope) -> WorkstreamSourceBucket {
let source = event.source.system.as_str().to_ascii_lowercase();
match source.as_str() {
"jira" | "linear" => WorkstreamSourceBucket::Ticket,
"manual" => WorkstreamSourceBucket::Manual,
"github" | "gitlab" | "local_git" | "localgit" => WorkstreamSourceBucket::Code,
_ => match event.payload {
EventPayload::PullRequest(_) | EventPayload::Review(_) => WorkstreamSourceBucket::Code,
EventPayload::Manual(_) => WorkstreamSourceBucket::Manual,
},
}
}
fn is_misc_workstream_title(workstream: &Workstream) -> bool {
let normalized = workstream.title.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"misc" | "miscellaneous" | "other" | "uncategorized" | "untriaged"
)
}
fn print_weekly_review(run_dir: &Path, strict: bool) -> Result<()> {
let ingest =
load_run_ingest(run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let coverage = ingest.coverage;
let events = ingest.events;
let skipped_sources = configured_source_skips(&coverage.warnings);
let counts = review_source_event_counts(&coverage.sources, &events, &skipped_sources);
println!("Weekly review: {}", coverage.run_id);
println!("Directory: {}", run_dir.display());
println!(
"Window: {}..{}",
coverage.window.since, coverage.window.until
);
println!();
println!("New evidence:");
println!("- total: {} event(s)", events.len());
if counts.is_empty() {
println!("- No included source events");
} else {
for (source, count) in counts {
println!("- {}: {} event(s)", display_source_label(&source), count);
}
}
if !skipped_sources.is_empty() {
println!("Source gaps:");
for skipped in &skipped_sources {
println!(
"- {}: {}",
display_source_label(&skipped.source),
skipped.reason
);
}
}
println!();
print_review(run_dir, strict)
}
fn print_review(run_dir: &Path, strict: bool) -> Result<()> {
let ingest =
load_run_ingest(run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let coverage = ingest.coverage;
let events = ingest.events;
let run_id = coverage.run_id.to_string();
let skipped_sources = configured_source_skips(&coverage.warnings);
let (workstreams, source, path) = load_effective_workstreams_for_run(run_dir)?;
let validation_errors = validate_workstreams_against_events(&workstreams, &events);
let signals = workstream_quality_signals(&workstreams, &events);
let evidence_debt = detect_evidence_debt(EvidenceDebtInput {
run_id: &run_id,
coverage: &coverage,
events: &events,
skipped_sources: &skipped_sources,
workstreams: &workstreams,
validation_errors: &validation_errors,
signals: &signals,
});
println!("Run: {run_id}");
println!("Directory: {}", run_dir.display());
println!(
"Window: {}..{}",
coverage.window.since, coverage.window.until
);
println!("User: {}", coverage.user);
println!();
println!("Coverage:");
let counts = review_source_event_counts(&coverage.sources, &events, &skipped_sources);
if counts.is_empty() {
println!("- No included source events");
} else {
for (source, count) in counts {
println!("- {}: {} event(s)", display_source_label(&source), count);
}
}
println!("Completeness: {}", coverage.completeness);
println!("Gaps: {}", coverage_gap_count(&coverage));
if !skipped_sources.is_empty() {
println!("Skipped sources:");
for skipped in &skipped_sources {
println!(
"- {}: {}",
display_source_label(&skipped.source),
skipped.reason
);
}
}
println!();
println!("Curation:");
println!(
"- Workstreams: {} ({})",
workstreams.workstreams.len(),
workstream_source_label(source)
);
println!("- Workstreams file: {}", path.display());
println!(
"- Workstreams with no selected receipts: {}",
signals.no_receipt_workstreams.len()
);
println!(
"- Workstreams with too many selected receipts: {}",
signals.too_many_receipt_workstreams.len()
);
println!(
"- Workstreams with 10+ events: {}",
signals.broad_workstreams.len()
);
println!("- Thin workstreams: {}", signals.thin_workstreams.len());
println!(
"- Large misc workstreams: {}",
signals.large_misc_workstreams.len()
);
if validation_errors.is_empty() {
println!("- Validation: ok");
} else {
println!("- Validation: {} issue(s)", validation_errors.len());
for error in validation_errors.iter().take(5) {
println!(" - {error}");
}
if validation_errors.len() > 5 {
println!(" - ... and {} more", validation_errors.len() - 5);
}
}
println!();
print_evidence_debt(&evidence_debt);
println!();
print_review_next_steps(
&run_id,
!validation_errors.is_empty(),
signals
.no_receipt_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
signals
.broad_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
signals
.manual_context_workstreams
.first()
.map(|workstream| workstream.title.as_str()),
!skipped_sources.is_empty(),
);
if strict && !evidence_debt.is_empty() {
anyhow::bail!(
"review found {} evidence debt item(s); rerun without --strict to inspect details",
evidence_debt.len()
);
}
Ok(())
}
fn print_review_fixups(
run_dir: &Path,
out_dir: &Path,
commands_only: bool,
journal_template: bool,
) -> Result<()> {
if commands_only && journal_template {
anyhow::bail!(
"review fixups accepts either --commands-only or --journal-template, not both"
);
}
let ingest =
load_run_ingest(run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
let coverage = ingest.coverage;
let events = ingest.events;
let run_id = coverage.run_id.to_string();
let skipped_sources = configured_source_skips(&coverage.warnings);
let (workstreams, _, _) = load_effective_workstreams_for_run(run_dir)?;
let validation_errors = validate_workstreams_against_events(&workstreams, &events);
let signals = workstream_quality_signals(&workstreams, &events);
let fixups = review_fixups(
&run_id,
out_dir,
&skipped_sources,
&validation_errors,
&signals,
);
if journal_template {
let templates = review_journal_templates(&signals);
if templates.is_empty() {
println!("# No journal templates found for {run_id}.");
} else {
for workstream in templates.iter().take(5) {
println!("{}", journal_add_template_next_step(&workstream.title));
}
}
return Ok(());
}
if commands_only {
if fixups.is_empty() {
println!(
"shiplog render --out {} --run {run_id} --mode scaffold",
quote_cli_value(&out_dir.display().to_string())
);
} else {
for fixup in fixups.iter().take(5) {
println!("{}", fixup.command);
}
}
return Ok(());
}
println!("Review fixups: {run_id}");
println!("Directory: {}", run_dir.display());
println!();
println!("Top fixups:");
if fixups.is_empty() {
println!("- No high-value fixups found.");
println!();
println!("Next:");
println!(
"1. shiplog render --out {} --run {run_id} --mode scaffold",
quote_cli_value(&out_dir.display().to_string())
);
return Ok(());
}
for (idx, fixup) in fixups.iter().take(5).enumerate() {
println!("{}. {}", idx + 1, fixup.title);
if let Some(detail) = &fixup.detail {
println!(" {detail}");
}
println!(" {}", fixup.command);
}
if fixups.len() > 5 {
println!("... and {} more fixup(s).", fixups.len() - 5);
}
println!();
println!("Next:");
println!(
"1. shiplog review --out {} --run {run_id}",
quote_cli_value(&out_dir.display().to_string())
);
println!(
"2. shiplog render --out {} --run {run_id} --mode scaffold",
quote_cli_value(&out_dir.display().to_string())
);
Ok(())
}
fn review_journal_templates<'a>(signals: &'a WorkstreamQualitySignals<'a>) -> Vec<&'a Workstream> {
let mut seen = HashSet::new();
let mut templates = Vec::new();
for workstream in signals
.manual_context_workstreams
.iter()
.chain(signals.ticket_only_workstreams.iter())
.chain(signals.code_only_workstreams.iter())
{
if seen.insert(workstream.title.clone()) {
templates.push(*workstream);
}
}
templates
}
fn review_fixups(
run_id: &str,
out_dir: &Path,
skipped_sources: &[ConfiguredSourceSkip],
validation_errors: &[String],
signals: &WorkstreamQualitySignals<'_>,
) -> Vec<ReviewFixup> {
let mut fixups = Vec::new();
let out_arg = quote_cli_value(&out_dir.display().to_string());
if !validation_errors.is_empty() {
fixups.push(ReviewFixup {
title: "Validate workstream assignments".to_string(),
detail: Some(format!(
"{} validation issue(s) should be fixed before sharing.",
validation_errors.len()
)),
command: format!("shiplog workstreams validate --out {out_arg} --run {run_id}"),
});
}
if !skipped_sources.is_empty() {
let sources = skipped_sources
.iter()
.take(3)
.map(|skipped| display_source_label(&skipped.source))
.collect::<Vec<_>>()
.join(", ");
let detail = if skipped_sources.len() > 3 {
format!(
"Skipped sources: {sources}; and {} more.",
skipped_sources.len() - 3
)
} else {
format!("Skipped sources: {sources}.")
};
fixups.push(ReviewFixup {
title: "Repair skipped source setup".to_string(),
detail: Some(detail),
command: "shiplog doctor".to_string(),
});
}
for workstream in signals.large_misc_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Split large misc workstream {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} event(s) are still in a miscellaneous bucket.",
workstream.events.len()
)),
command: format!(
"shiplog workstreams split --out {out_arg} --run {run_id} --from {} --to \"<new workstream>\" --matching \"<pattern>\" --create",
quote_cli_value(&workstream.title)
),
});
}
for workstream in signals.manual_context_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Add outcome context for {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} event(s) are grouped here, but none are manual outcome notes.",
workstream.events.len()
)),
command: journal_add_next_step(&workstream.title),
});
}
for workstream in signals.no_receipt_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Select anchor receipts for {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} event(s) are assigned, but no receipt anchors are selected.",
workstream.events.len()
)),
command: format!(
"shiplog workstreams receipts --out {out_arg} --run {run_id} --workstream {}",
quote_cli_value(&workstream.title)
),
});
}
for workstream in signals.too_many_receipt_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Trim selected receipts for {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} receipt anchors are selected; choose the strongest few for review.",
workstream.receipts.len()
)),
command: format!(
"shiplog workstreams receipts --out {out_arg} --run {run_id} --workstream {}",
quote_cli_value(&workstream.title)
),
});
}
for workstream in signals.broad_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Split broad workstream {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} event(s) may be too broad for one review claim.",
workstream.events.len()
)),
command: format!(
"shiplog workstreams split --out {out_arg} --run {run_id} --from {} --to \"<new workstream>\" --matching \"<pattern>\" --create",
quote_cli_value(&workstream.title)
),
});
}
for workstream in signals.ticket_only_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Add outcome context for ticket-only workstream {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} ticket event(s) are grouped here without code or manual context.",
workstream.events.len()
)),
command: journal_add_next_step(&workstream.title),
});
}
for workstream in signals.code_only_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Add outcome context for code-only workstream {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} code/review event(s) are grouped here without ticket or manual context.",
workstream.events.len()
)),
command: journal_add_next_step(&workstream.title),
});
}
for workstream in signals.manual_only_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Check manual-only workstream {}",
quote_display_title(&workstream.title)
),
detail: Some(format!(
"{} manual event(s) are grouped here without external source receipts.",
workstream.events.len()
)),
command: "shiplog journal list".to_string(),
});
}
for workstream in signals.thin_workstreams.iter().take(2) {
fixups.push(ReviewFixup {
title: format!(
"Check thin workstream {}",
quote_display_title(&workstream.title)
),
detail: Some("Only one event is assigned; confirm it can stand alone.".to_string()),
command: format!(
"shiplog workstreams receipts --out {out_arg} --run {run_id} --workstream {}",
quote_cli_value(&workstream.title)
),
});
}
fixups
}
fn detect_evidence_debt(input: EvidenceDebtInput<'_>) -> Vec<EvidenceDebt> {
let mut debt = Vec::new();
for skipped in input.skipped_sources {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::MissingSource,
format!(
"{} was skipped: {}",
display_source_label(&skipped.source),
skipped.reason
),
)
.next_step("Run `shiplog doctor` to check source configuration and tokens."),
);
}
if input.coverage.completeness != shiplog_schema::coverage::Completeness::Complete {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::PartialCoverage,
format!(
"Coverage is {}; inspect coverage.manifest.json before making strong claims.",
input.coverage.completeness
),
)
.next_step("Inspect coverage.manifest.json before sharing this packet."),
);
}
for warning in input
.coverage
.warnings
.iter()
.filter(|warning| configured_source_skip(warning).is_none())
{
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::CoverageWarning,
warning.clone(),
)
.next_step(format!(
"Run `shiplog runs show --run {}` to inspect this run.",
input.run_id
)),
);
}
for slice in input.coverage.slices.iter().filter(|slice| {
slice.incomplete_results.unwrap_or(false) || slice.fetched < slice.total_count
}) {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::IncompleteQuery,
format!(
"Query {:?} fetched {}/{} result(s).",
slice.query, slice.fetched, slice.total_count
),
)
.next_step(
"Run `shiplog intake --last-6-months --explain` after repairing source setup.",
),
);
}
if input.signals.manual_events > 0 {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::ManualContext,
"Manual events are user-provided; keep context current before sharing.",
)
.next_step("Run `shiplog journal list` to inspect manual evidence."),
);
}
if !input.signals.manual_context_workstreams.is_empty() {
let first = input.signals.manual_context_workstreams[0];
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::ManualContext,
format!(
"{} broad workstream(s) have no manual outcome note.",
input.signals.manual_context_workstreams.len()
),
)
.detail(workstream_title_sample(
&input.signals.manual_context_workstreams,
))
.next_step(journal_add_next_step(&first.title)),
);
}
if !input.signals.no_receipt_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::MissingReceiptAnchors,
format!(
"{} workstream(s) have no selected receipt anchors.",
input.signals.no_receipt_workstreams.len()
),
)
.detail(workstream_title_sample(
&input.signals.no_receipt_workstreams,
))
.next_step(format!(
"Run `shiplog workstreams receipts --run {} --workstream <title>`.",
input.run_id
)),
);
}
if !input.signals.too_many_receipt_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::TooManySelectedReceipts,
format!(
"{} workstream(s) have more than {} selected receipt anchors.",
input.signals.too_many_receipt_workstreams.len(),
TOO_MANY_SELECTED_RECEIPTS_THRESHOLD
),
)
.detail(workstream_title_sample(&input.signals.too_many_receipt_workstreams))
.next_step(format!(
"Run `shiplog workstreams receipts --run {} --workstream <title>` and keep the strongest anchors.",
input.run_id
)),
);
}
if !input.signals.thin_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::ThinWorkstream,
format!(
"{} workstream(s) have only one assigned event.",
input.signals.thin_workstreams.len()
),
)
.detail(workstream_title_sample(&input.signals.thin_workstreams))
.next_step(format!(
"Run `shiplog workstreams receipts --run {} --workstream <title>` to confirm the anchor.",
input.run_id
)),
);
}
if !input.signals.large_misc_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Warning,
EvidenceDebtKind::LargeMiscWorkstream,
format!(
"{} miscellaneous workstream(s) have {}+ events.",
input.signals.large_misc_workstreams.len(),
LARGE_MISC_WORKSTREAM_EVENT_THRESHOLD
),
)
.detail(workstream_title_sample(&input.signals.large_misc_workstreams))
.next_step(format!(
"Run `shiplog workstreams split --run {} --from <title> --to \"<new workstream>\" --matching \"<pattern>\" --create`.",
input.run_id
)),
);
}
if !input.signals.code_only_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::CodeOnlyWorkstream,
format!(
"{} workstream(s) only have code or review receipts.",
input.signals.code_only_workstreams.len()
),
)
.detail(workstream_title_sample(
&input.signals.code_only_workstreams,
))
.next_step(journal_add_next_step(
&input.signals.code_only_workstreams[0].title,
)),
);
}
if !input.signals.ticket_only_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::TicketOnlyWorkstream,
format!(
"{} workstream(s) only have ticket receipts.",
input.signals.ticket_only_workstreams.len()
),
)
.detail(workstream_title_sample(
&input.signals.ticket_only_workstreams,
))
.next_step(journal_add_next_step(
&input.signals.ticket_only_workstreams[0].title,
)),
);
}
if !input.signals.manual_only_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::ManualOnlyWorkstream,
format!(
"{} workstream(s) only have manual evidence.",
input.signals.manual_only_workstreams.len()
),
)
.detail(workstream_title_sample(
&input.signals.manual_only_workstreams,
))
.next_step("Run `shiplog journal list` and attach external receipts where available."),
);
}
if !input.signals.broad_workstreams.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Info,
EvidenceDebtKind::BroadWorkstream,
format!(
"{} workstream(s) have 10+ events; consider splitting broad buckets.",
input.signals.broad_workstreams.len()
),
)
.detail(workstream_title_sample(&input.signals.broad_workstreams))
.next_step(format!(
"Run `shiplog workstreams split --run {} --from <title> --to \"<new workstream>\" --matching \"<pattern>\" --create`.",
input.run_id
)),
);
}
if !input.validation_errors.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Blocking,
EvidenceDebtKind::WorkstreamValidation,
"Workstream validation needs attention before rendering.",
)
.detail(format!(
"{} validation issue(s) found across {} workstream(s).",
input.validation_errors.len(),
input.workstreams.workstreams.len()
))
.next_step(format!(
"Run `shiplog workstreams validate --run {}`.",
input.run_id
)),
);
}
let assigned_events: usize = input
.workstreams
.workstreams
.iter()
.map(|workstream| workstream.events.len())
.sum();
if assigned_events == 0 && !input.events.is_empty() {
debt.push(
EvidenceDebt::new(
EvidenceDebtSeverity::Blocking,
EvidenceDebtKind::WorkstreamValidation,
"Ledger has events but no workstream assignments.",
)
.next_step(format!(
"Run `shiplog workstreams validate --run {}`.",
input.run_id
)),
);
}
debt
}
fn print_evidence_debt(debt: &[EvidenceDebt]) {
println!("Evidence debt:");
if debt.is_empty() {
println!("- No obvious evidence debt detected.");
return;
}
for item in debt {
println!(
"- [{}] {}: {}",
item.severity.label(),
item.kind.label(),
item.summary
);
if let Some(detail) = &item.detail {
println!(" Detail: {detail}");
}
if let Some(next_step) = &item.next_step {
println!(" Next: {next_step}");
}
}
}
fn workstream_title_sample(workstreams: &[&Workstream]) -> String {
let mut titles = workstreams
.iter()
.take(3)
.map(|workstream| workstream.title.as_str())
.collect::<Vec<_>>();
titles.sort_unstable();
let mut detail = format!("Examples: {}", titles.join(", "));
if workstreams.len() > titles.len() {
detail.push_str(&format!("; and {} more", workstreams.len() - titles.len()));
}
detail
}
fn print_review_next_steps(
run_id: &str,
has_validation_errors: bool,
first_no_receipt_workstream: Option<&str>,
first_broad_workstream: Option<&str>,
first_manual_context_workstream: Option<&str>,
has_skipped_sources: bool,
) {
println!("Next:");
let mut step = 1usize;
if has_validation_errors {
println!("{step}. shiplog workstreams validate --run {run_id}");
step += 1;
}
if let Some(title) = first_no_receipt_workstream {
println!(
"{step}. shiplog workstreams receipts --run {run_id} --workstream {}",
quote_cli_value(title)
);
step += 1;
}
if let Some(title) = first_broad_workstream {
println!(
"{step}. shiplog workstreams split --run {run_id} --from {} --to \"<new workstream>\" --matching \"<pattern>\" --create",
quote_cli_value(title)
);
step += 1;
}
if let Some(title) = first_manual_context_workstream {
println!("{step}. {}", journal_add_next_step(title));
step += 1;
}
if has_skipped_sources {
println!("{step}. shiplog doctor");
step += 1;
}
println!("{step}. shiplog render --run {run_id} --mode scaffold");
}
fn journal_add_next_step(workstream_title: &str) -> String {
format!(
"shiplog journal add --date {} --title {} --workstream {}",
Utc::now().date_naive(),
quote_cli_value(&format!("Outcome note for {workstream_title}")),
quote_cli_value(workstream_title)
)
}
fn journal_add_template_next_step(workstream_title: &str) -> String {
format!(
"shiplog journal add --date {} --title {} --workstream {} --description {}",
Utc::now().date_naive(),
quote_cli_value(&format!("Outcome note for {workstream_title}")),
quote_cli_value(workstream_title),
quote_cli_value("<replace with factual context or outcome>")
)
}
fn quote_display_title(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\\\""))
}
fn quote_cli_value(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\\\""))
}
fn review_source_event_counts(
manifest_sources: &[String],
events: &[EventEnvelope],
skipped_sources: &[ConfiguredSourceSkip],
) -> Vec<(String, usize)> {
let mut ordered_sources = Vec::new();
for source in manifest_sources {
if !skipped_sources
.iter()
.any(|skipped| sources_match(&skipped.source, source))
{
push_review_source(&mut ordered_sources, source);
}
}
for event in events {
push_review_source(&mut ordered_sources, event.source.system.as_str());
}
ordered_sources
.into_iter()
.filter_map(|source| {
let count = source_event_count_for_review(events, &source);
(count > 0).then_some((source, count))
})
.collect()
}
fn push_review_source(sources: &mut Vec<String>, candidate: &str) {
if sources
.iter()
.any(|source| sources_match(source, candidate))
{
return;
}
sources.push(candidate.to_string());
}
fn source_event_count_for_review(events: &[EventEnvelope], source: &str) -> usize {
events
.iter()
.filter(|event| sources_match(event.source.system.as_str(), source))
.count()
}
fn configured_source_skips(warnings: &[String]) -> Vec<ConfiguredSourceSkip> {
warnings
.iter()
.filter_map(|warning| configured_source_skip(warning))
.collect()
}
fn configured_source_skip(warning: &str) -> Option<ConfiguredSourceSkip> {
const PREFIX: &str = "Configured source ";
const INFIX: &str = " was skipped: ";
let rest = warning.strip_prefix(PREFIX)?;
let (source, reason) = rest.split_once(INFIX)?;
Some(ConfiguredSourceSkip {
source: source.to_string(),
reason: reason.to_string(),
})
}
fn sources_match(left: &str, right: &str) -> bool {
normalized_source_key(left) == normalized_source_key(right)
}
fn normalized_source_key(source: &str) -> String {
match source
.trim()
.to_ascii_lowercase()
.replace('-', "_")
.as_str()
{
"json_import" | "jsonimport" => "json".to_string(),
"local_git" | "localgit" => "git".to_string(),
other => other.to_string(),
}
}
fn display_source_label(source: &str) -> String {
match normalized_source_key(source).as_str() {
"github" => "GitHub".to_string(),
"gitlab" => "GitLab".to_string(),
"jira" => "Jira".to_string(),
"linear" => "Linear".to_string(),
"manual" => "Manual".to_string(),
"json" => "JSON".to_string(),
"git" => "Local git".to_string(),
"unknown" => "Unknown".to_string(),
other => other.to_string(),
}
}
fn source_list_label(sources: &[String]) -> String {
if sources.is_empty() {
"-".to_string()
} else {
sources.join(", ")
}
}
fn open_existing_path(path: &Path, label: &str, next_step: &str, print_path: bool) -> Result<()> {
if !path.exists() {
anyhow::bail!("{label} not found: {}. {next_step}", path.display());
}
open_or_print_path(path, print_path)
}
fn open_or_print_path(path: &Path, print_path: bool) -> Result<()> {
let display_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if print_path {
println!("{}", display_path.display());
return Ok(());
}
if try_open_path(&display_path) {
println!("Opened: {}", display_path.display());
} else {
println!("{}", display_path.display());
}
Ok(())
}
fn try_open_path(path: &Path) -> bool {
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("explorer.exe");
command.arg(path);
command
};
#[cfg(target_os = "macos")]
let mut command = {
let mut command = std::process::Command::new("open");
command.arg(path);
command
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut command = {
let mut command = std::process::Command::new("xdg-open");
command.arg(path);
command
};
#[cfg(not(any(target_os = "windows", target_os = "macos", unix)))]
{
let _ = path;
return false;
}
#[cfg(any(target_os = "windows", target_os = "macos", unix))]
{
command.stdin(std::process::Stdio::null());
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::null());
command
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
fn find_most_recent_run(out_dir: &Path) -> Result<PathBuf> {
if !out_dir.exists() {
anyhow::bail!("Output directory {:?} does not exist.", out_dir);
}
let mut runs: Vec<_> = std::fs::read_dir(out_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter(|e| e.path().join("ledger.events.jsonl").exists())
.collect();
runs.sort_by(|a, b| {
let a_meta = a.metadata().and_then(|m| m.modified()).ok();
let b_meta = b.metadata().and_then(|m| m.modified()).ok();
b_meta.cmp(&a_meta)
});
runs.into_iter()
.next()
.map(|e| e.path())
.ok_or_else(|| anyhow::anyhow!("No run directories found in {:?}", out_dir))
}
fn resolve_render_run_dir(out_dir: &Path, run: Option<String>, latest: bool) -> Result<PathBuf> {
if latest && run.is_some() {
anyhow::bail!("use either --latest or --run, not both")
}
match run.as_deref() {
Some("latest") | None => find_most_recent_run(out_dir),
Some(run_id) => Ok(out_dir.join(run_id)),
}
}
fn resolve_review_run_dir(
out_dir: &Path,
run: Option<String>,
latest: bool,
config_path: &Path,
period: Option<String>,
) -> Result<PathBuf> {
if let Some(period) = period {
if latest || run.is_some() {
anyhow::bail!("use --period without --latest or --run")
}
return resolve_period_run_dir(out_dir, config_path, &period);
}
resolve_render_run_dir(out_dir, run, latest)
}
fn resolve_period_run_dir(out_dir: &Path, config_path: &Path, period: &str) -> Result<PathBuf> {
let config = load_config_for_command(config_path)?;
ensure_supported_config_version(&config)?;
let window = resolve_config_period(&config, period)?;
for run_dir in discover_run_dirs(out_dir)? {
let ingest =
load_run_ingest(&run_dir).with_context(|| format!("load run {}", run_dir.display()))?;
if ingest.coverage.window.since == window.since
&& ingest.coverage.window.until == window.until
{
return Ok(run_dir);
}
}
anyhow::bail!(
"no run found for period {period:?} ({}..{}); run `shiplog intake --period {}` first",
window.since,
window.until,
quote_cli_value(period)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn date_args() -> DateArgs {
DateArgs {
since: None,
until: None,
last_6_months: false,
last_quarter: false,
year: None,
}
}
#[test]
fn resolve_cache_dir_uses_default_out_cache() {
let out_root = Path::new("C:/tmp/shiplog-out");
let resolved = resolve_cache_dir(out_root, None, false);
assert_eq!(resolved, Some(out_root.join(".cache")));
}
#[test]
fn resolve_cache_dir_uses_explicit_cache_path() {
let out_root = Path::new("C:/tmp/shiplog-out");
let explicit = PathBuf::from("D:/cache-root");
let resolved = resolve_cache_dir(out_root, Some(explicit.clone()), false);
assert_eq!(resolved, Some(explicit));
}
#[test]
fn resolve_cache_dir_disables_cache_when_requested() {
let out_root = Path::new("C:/tmp/shiplog-out");
let explicit = PathBuf::from("D:/cache-root");
let resolved = resolve_cache_dir(out_root, Some(explicit), true);
assert_eq!(resolved, None);
}
#[test]
fn cache_db_targets_default_to_known_api_cache_files() {
let root = Path::new("C:/tmp/shiplog-out/.cache");
let targets = cache_db_targets(root, &[]);
let files: Vec<_> = targets
.iter()
.map(|target| {
target
.path
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
})
.collect();
assert_eq!(
files,
vec![
"github-api-cache.db",
"gitlab-api-cache.db",
"jira-api-cache.db",
"linear-api-cache.db",
]
);
}
#[test]
fn selected_cache_sources_preserve_order_and_dedupe() {
let selected = selected_cache_sources(&[
CacheSource::Jira,
CacheSource::Github,
CacheSource::Jira,
CacheSource::Linear,
]);
assert_eq!(
selected,
vec![CacheSource::Jira, CacheSource::Github, CacheSource::Linear]
);
}
#[test]
fn parse_cache_age_accepts_days_hours_and_minutes() {
assert_eq!(parse_cache_age("30d").unwrap(), Duration::days(30));
assert_eq!(parse_cache_age("12h").unwrap(), Duration::hours(12));
assert_eq!(parse_cache_age("90m").unwrap(), Duration::minutes(90));
}
#[test]
fn parse_cache_age_rejects_invalid_values() {
assert!(parse_cache_age("30").is_err());
assert!(parse_cache_age("-1d").is_err());
assert!(parse_cache_age("1w").is_err());
}
#[test]
fn cache_clean_mode_rejects_all_with_older_than() {
let args = CacheCleanArgs {
out: PathBuf::from("./out"),
cache_dir: None,
sources: Vec::new(),
older_than: Some("30d".to_string()),
all: true,
dry_run: false,
yes: true,
};
let err = cache_clean_mode(&args).unwrap_err();
assert!(err.to_string().contains("either --all or --older-than"));
}
#[test]
fn render_options_use_profile_specific_packet_defaults() {
let internal = cli_render_options(
RenderPacketMode::Packet,
None,
None,
&BundleProfile::Internal,
);
assert_eq!(internal.receipt_limit, WORKSTREAM_RECEIPT_RENDER_LIMIT);
assert_eq!(internal.appendix_mode, AppendixMode::Summary);
let manager = cli_render_options(
RenderPacketMode::Packet,
None,
None,
&BundleProfile::Manager,
);
assert_eq!(manager.receipt_limit, MANAGER_RECEIPT_RENDER_LIMIT);
assert_eq!(manager.appendix_mode, AppendixMode::Summary);
let public =
cli_render_options(RenderPacketMode::Packet, None, None, &BundleProfile::Public);
assert_eq!(public.receipt_limit, PUBLIC_RECEIPT_RENDER_LIMIT);
assert_eq!(public.appendix_mode, AppendixMode::None);
}
#[test]
fn render_options_keep_explicit_receipt_and_appendix_overrides() {
let options = cli_render_options(
RenderPacketMode::Packet,
Some(7),
Some(RenderAppendixMode::Full),
&BundleProfile::Public,
);
assert_eq!(options.receipt_limit, 7);
assert_eq!(options.appendix_mode, AppendixMode::Full);
}
#[test]
fn receipts_mode_keeps_audit_defaults_for_share_profiles() {
let options = cli_render_options(
RenderPacketMode::Receipts,
None,
None,
&BundleProfile::Public,
);
assert_eq!(options.receipt_limit, WORKSTREAM_RECEIPT_RENDER_LIMIT);
assert_eq!(options.appendix_mode, AppendixMode::Full);
}
#[test]
fn resolve_date_window_uses_explicit_dates() {
let mut args = date_args();
args.since = Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
args.until = Some(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap());
args.last_6_months = true;
let window =
resolve_date_window_for_today(args, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap())
.unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2025, 2, 1).unwrap());
assert_eq!(window.label, WindowLabel::Explicit);
}
#[test]
fn resolve_date_window_defaults_to_last_six_months() {
let window = resolve_date_window_for_today(
date_args(),
NaiveDate::from_ymd_opt(2026, 5, 7).unwrap(),
)
.unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2025, 11, 7).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap());
assert_eq!(window.label, WindowLabel::LastSixMonths);
assert_eq!(
window.window_label(),
"last-6-months (2025-11-07..2026-05-07)"
);
}
#[test]
fn resolve_date_window_uses_last_quarter() {
let mut args = date_args();
args.last_quarter = true;
let window =
resolve_date_window_for_today(args, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap())
.unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
assert_eq!(window.label, WindowLabel::LastQuarter);
}
#[test]
fn resolve_date_window_uses_year() {
let mut args = date_args();
args.year = Some(2025);
let window =
resolve_date_window_for_today(args, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap())
.unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(window.label, WindowLabel::Year(2025));
}
#[test]
fn resolve_multi_window_uses_named_period_with_explicit_dates() {
let mut config = ShiplogConfig::default();
config.periods.insert(
"review-cycle".to_string(),
ConfigPeriod {
since: Some(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
until: Some(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()),
preset: None,
},
);
let args = ConfigWindowArgs {
period: Some("review-cycle".to_string()),
..ConfigWindowArgs::default()
};
let window = resolve_multi_window(args, &config).unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap());
assert_eq!(window.period.as_deref(), Some("review-cycle"));
assert_eq!(
window.window_label(),
"review-cycle (2026-01-01..2026-07-01)"
);
}
#[test]
fn resolve_multi_window_uses_named_period_preset() {
let mut config = ShiplogConfig::default();
config.periods.insert(
"h1".to_string(),
ConfigPeriod {
preset: Some("year:2025".to_string()),
..ConfigPeriod::default()
},
);
let args = ConfigWindowArgs {
period: Some("h1".to_string()),
..ConfigWindowArgs::default()
};
let window = resolve_multi_window(args, &config).unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(window.period.as_deref(), Some("h1"));
assert_eq!(window.label, WindowLabel::Year(2025));
}
#[test]
fn resolve_multi_window_cli_dates_override_period() {
let mut config = ShiplogConfig::default();
config.periods.insert(
"review-cycle".to_string(),
ConfigPeriod {
since: Some(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
until: Some(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()),
preset: None,
},
);
let mut dates = date_args();
dates.year = Some(2025);
let args = ConfigWindowArgs {
dates,
period: Some("review-cycle".to_string()),
};
let window = resolve_multi_window(args, &config).unwrap();
assert_eq!(window.since, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
assert_eq!(window.until, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(window.period, None);
}
#[test]
fn resolve_multi_window_rejects_unknown_period() {
let args = ConfigWindowArgs {
period: Some("missing".to_string()),
..ConfigWindowArgs::default()
};
let err = resolve_multi_window(args, &ShiplogConfig::default()).unwrap_err();
assert!(err.to_string().contains("unknown period"));
}
#[test]
fn resolve_multi_window_rejects_invalid_period_shape() {
let mut config = ShiplogConfig::default();
config.periods.insert(
"broken".to_string(),
ConfigPeriod {
since: Some(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
preset: Some("last-6-months".to_string()),
..ConfigPeriod::default()
},
);
let args = ConfigWindowArgs {
period: Some("broken".to_string()),
..ConfigWindowArgs::default()
};
let err = resolve_multi_window(args, &config).unwrap_err();
assert!(err.to_string().contains("either preset or since/until"));
}
#[test]
fn resolve_date_window_rejects_partial_explicit_dates() {
let mut args = date_args();
args.since = Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
let err = resolve_date_window_for_today(args, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap())
.unwrap_err();
assert!(err.to_string().contains("provide both --since"));
}
#[test]
fn resolve_date_window_rejects_multiple_presets() {
let mut args = date_args();
args.last_6_months = true;
args.last_quarter = true;
let err = resolve_date_window_for_today(args, NaiveDate::from_ymd_opt(2026, 5, 7).unwrap())
.unwrap_err();
assert!(err.to_string().contains("choose only one date preset"));
}
#[test]
fn resolve_render_run_dir_rejects_latest_and_run() {
let err = resolve_render_run_dir(Path::new("out"), Some("run_fixture".to_string()), true)
.unwrap_err();
assert!(err.to_string().contains("either --latest or --run"));
}
#[test]
fn resolve_render_run_dir_treats_run_latest_as_most_recent() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("run_fixture");
std::fs::create_dir(&run_dir).unwrap();
std::fs::write(run_dir.join("ledger.events.jsonl"), "").unwrap();
let resolved =
resolve_render_run_dir(tmp.path(), Some("latest".to_string()), false).unwrap();
assert_eq!(resolved, run_dir);
}
#[test]
fn make_github_ingestor_configures_enterprise_api_base_and_cache() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let ing = make_github_ingestor(
"alice",
since,
until,
"merged",
true,
false,
10,
Some("ghp-token".to_string()),
"https://github.enterprise.example/api/v3",
Some(cache_dir.path().to_path_buf()),
)
.unwrap();
assert_eq!(ing.user, "alice");
assert_eq!(ing.since, since);
assert_eq!(ing.until, until);
assert_eq!(ing.mode, "merged");
assert!(ing.include_reviews);
assert!(ing.fetch_details);
assert_eq!(ing.throttle_ms, 10);
assert_eq!(ing.token.as_deref(), Some("ghp-token"));
assert_eq!(ing.api_base, "https://github.enterprise.example/api/v3");
assert!(ing.cache.is_some());
assert!(cache_dir.path().join("github-api-cache.db").exists());
}
#[test]
fn make_gitlab_ingestor_configures_cli_options() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let ing = make_gitlab_ingestor(
"alice",
since,
until,
"closed",
"https://gitlab.example.com/platform/reliability",
true,
25,
Some("glpat-token".to_string()),
Some(cache_dir.path().to_path_buf()),
)
.unwrap();
assert_eq!(ing.user, "alice");
assert_eq!(ing.since, since);
assert_eq!(ing.until, until);
assert_eq!(ing.state, MrState::Closed);
assert_eq!(ing.instance, "gitlab.example.com");
assert!(ing.include_reviews);
assert_eq!(ing.throttle_ms, 25);
assert_eq!(ing.token.as_deref(), Some("glpat-token"));
assert!(ing.cache.is_some());
assert!(cache_dir.path().join("gitlab-api-cache.db").exists());
}
#[test]
fn make_gitlab_ingestor_rejects_invalid_state() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let err = make_gitlab_ingestor(
"alice",
since,
until,
"invalid",
"gitlab.com",
false,
0,
Some("glpat-token".to_string()),
None,
)
.unwrap_err();
assert!(
err.to_string().contains("parse GitLab MR state"),
"unexpected error: {err:?}"
);
}
#[test]
fn normalize_jira_instance_accepts_host_and_url() {
assert_eq!(
normalize_jira_instance("company.atlassian.net").unwrap(),
"company.atlassian.net"
);
assert_eq!(
normalize_jira_instance("https://company.atlassian.net").unwrap(),
"company.atlassian.net"
);
}
#[test]
fn jira_api_url_uses_myself_endpoint_base() {
assert_eq!(
jira_api_url("https://company.atlassian.net", "/myself").unwrap(),
"https://company.atlassian.net/rest/api/3/myself"
);
}
#[test]
fn linear_graphql_url_matches_adapter_endpoint() {
assert_eq!(linear_graphql_url(), "https://api.linear.app/graphql");
}
#[test]
fn make_jira_ingestor_configures_cli_options() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let ing = make_jira_ingestor(
"712020:account-id",
Some("alice@example.com".to_string()),
since,
until,
"done",
"https://company.atlassian.net",
50,
Some("jira-token".to_string()),
Some(cache_dir.path().to_path_buf()),
)
.unwrap();
assert_eq!(ing.user, "712020:account-id");
assert_eq!(ing.auth_user.as_deref(), Some("alice@example.com"));
assert_eq!(ing.since, since);
assert_eq!(ing.until, until);
assert_eq!(ing.status, IssueStatus::Done);
assert_eq!(ing.instance, "company.atlassian.net");
assert_eq!(ing.throttle_ms, 50);
assert_eq!(ing.token.as_deref(), Some("jira-token"));
assert!(ing.cache.is_some());
assert!(cache_dir.path().join("jira-api-cache.db").exists());
}
#[test]
fn make_jira_ingestor_rejects_invalid_status() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let err = make_jira_ingestor(
"alice@example.com",
None,
since,
until,
"invalid",
"company.atlassian.net",
0,
Some("jira-token".to_string()),
None,
)
.unwrap_err();
assert!(
err.to_string().contains("parse Jira issue status"),
"unexpected error: {err:?}"
);
}
#[test]
fn make_linear_ingestor_configures_cli_options() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let ing = make_linear_ingestor(
"linear-user-id",
since,
until,
"in_progress",
Some("OPS".to_string()),
75,
Some("linear-key".to_string()),
Some(cache_dir.path().to_path_buf()),
)
.unwrap();
assert_eq!(ing.user, "linear-user-id");
assert_eq!(ing.since, since);
assert_eq!(ing.until, until);
assert_eq!(ing.status, LinearIssueStatus::InProgress);
assert_eq!(ing.project.as_deref(), Some("OPS"));
assert_eq!(ing.throttle_ms, 75);
assert_eq!(ing.api_key.as_deref(), Some("linear-key"));
assert!(ing.cache.is_some());
assert!(cache_dir.path().join("linear-api-cache.db").exists());
}
#[test]
fn make_linear_ingestor_rejects_invalid_status() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let err = make_linear_ingestor(
"linear-user-id",
since,
until,
"invalid",
None,
0,
Some("linear-key".to_string()),
None,
)
.unwrap_err();
assert!(
err.to_string().contains("parse Linear issue status"),
"unexpected error: {err:?}"
);
}
#[test]
fn make_git_ingestor_configures_cli_options() {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let repo = Path::new(".");
let ing = make_git_ingestor(
repo,
since,
until,
Some("dev@example.com".to_string()),
true,
);
assert_eq!(ing.repo_path, repo);
assert_eq!(ing.since, since);
assert_eq!(ing.until, until);
assert_eq!(ing.author.as_deref(), Some("dev@example.com"));
assert!(ing.include_merges);
}
}