use anyhow::{Context, Result};
use chrono::NaiveDate;
use clap::{Parser, Subcommand};
use shiplog_engine::{Engine, WorkstreamSource};
use shiplog_ingest_git::LocalGitIngestor;
use shiplog_ingest_github::GithubIngestor;
use shiplog_ingest_json::JsonIngestor;
use shiplog_ingest_manual::ManualIngestor;
use shiplog_ports::Ingestor;
use shiplog_redact::DeterministicRedactor;
use shiplog_render_md::MarkdownRenderer;
use shiplog_schema::bundle::BundleProfile;
use shiplog_workstreams::RepoClusterer;
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 {
Collect {
#[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)]
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, 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,
},
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,
},
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(Subcommand, Debug, Clone)]
enum Source {
Github {
#[arg(long)]
user: String,
#[arg(long)]
since: NaiveDate,
#[arg(long)]
until: NaiveDate,
#[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,
},
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,
#[arg(long)]
since: NaiveDate,
#[arg(long)]
until: NaiveDate,
},
Git {
#[arg(long)]
repo: PathBuf,
#[arg(long)]
since: NaiveDate,
#[arg(long)]
until: NaiveDate,
#[arg(long)]
author: Option<String>,
#[arg(long)]
include_merges: bool,
},
}
fn get_redact_key(redact_key: Option<String>) -> String {
redact_key
.or_else(|| std::env::var("SHIPLOG_REDACT_KEY").ok())
.unwrap_or_else(|| {
eprintln!("WARN: no redaction key provided; using a default dev key. Don't share public packets like this.");
"dev-key".to_string()
})
}
fn create_engine(
redact_key: &str,
clusterer: Box<dyn shiplog_ports::WorkstreamClusterer>,
) -> (Engine<'static>, &'static DeterministicRedactor) {
let renderer = Box::new(MarkdownRenderer::default());
let redactor = DeterministicRedactor::new(redact_key.as_bytes());
let renderer: &'static dyn shiplog_ports::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,
)
}
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")))
}
}
#[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)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Command::Collect {
source,
out,
zip,
redact_key,
bundle_profile,
regen,
llm_cluster,
llm_api_endpoint,
llm_model,
llm_api_key,
} => {
let key = get_redact_key(redact_key);
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) = create_engine(&key, clusterer);
match source {
Source::Github {
user,
since,
until,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_github_ingestor(
&user,
since,
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 = format!("{}..{}", since, until);
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::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,
since,
until,
} => {
let ing = ManualIngestor::new(&events, user.clone(), since, 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 = format!("{}..{}", since, until);
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,
since,
until,
author,
include_merges,
} => {
let mut ing = LocalGitIngestor::new(&repo, since, until);
if let Some(a) = author {
ing = ing.with_author(a);
}
if include_merges {
ing = ing.with_merges(true);
}
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 = format!("{}..{}", since, until);
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,
user,
window_label,
redact_key,
bundle_profile,
zip,
} => {
let key = get_redact_key(redact_key);
let clusterer: Box<dyn shiplog_ports::WorkstreamClusterer> = Box::new(RepoClusterer);
let (engine, redactor) = create_engine(&key, clusterer);
let run_dir = if let Some(run_id) = run {
out.join(run_id)
} else {
find_most_recent_run(&out)?
};
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 cache_path = DeterministicRedactor::cache_path(&run_dir);
let _ = redactor.load_cache(&cache_path);
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!("Rendered from existing events:");
print_outputs(&outputs, WorkstreamSource::Curated);
}
Command::Refresh {
source,
out,
run_dir: explicit_run_dir,
zip,
redact_key,
bundle_profile,
} => {
let key = get_redact_key(redact_key);
let clusterer: Box<dyn shiplog_ports::WorkstreamClusterer> = Box::new(RepoClusterer);
let (engine, redactor) = create_engine(&key, clusterer);
let run_dir = if let Some(rd) = explicit_run_dir {
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 { .. } => {
eprintln!("ERROR: Git source not yet implemented");
std::process::exit(1);
}
Source::Github {
user,
since,
until,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
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,
since,
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 = format!("{}..{}", since, until);
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::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,
since,
until,
} => {
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(), since, until);
let ingest = ing.ingest().context("ingest events")?;
let window_label = format!("{}..{}", since, until);
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::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 key = get_redact_key(redact_key);
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) = create_engine(&key, clusterer);
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 key = get_redact_key(redact_key);
let clusterer =
build_clusterer(llm_cluster, &llm_api_endpoint, &llm_model, llm_api_key);
let (engine, redactor) = create_engine(&key, clusterer);
match source {
Source::Git { .. } => {
eprintln!("ERROR: Git source not yet implemented");
std::process::exit(1);
}
Source::Github {
user,
since,
until,
mode,
include_reviews,
no_details,
throttle_ms,
token,
api_base,
cache_dir,
no_cache,
} => {
let cache_dir = resolve_cache_dir(&out, cache_dir, no_cache);
let ing = make_github_ingestor(
&user,
since,
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 = format!("{}..{}", since, until);
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::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,
since,
until,
} => {
let ing = ManualIngestor::new(&events, user.clone(), since, 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 = format!("{}..{}", since, until);
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 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 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))
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
}