use std::io::ErrorKind;
use std::path::PathBuf;
use gobby_core::ai::AiNoticeKind;
use gobby_core::config::AiRouting;
use crate::explainer::{ExplainerGenerator, ExplainerPrompt, ExplainerReport};
use crate::sources::SourceManifest;
use crate::support::scope::{resolve_command_scope, resolved_scope_identity};
use crate::{
CommandOutcome, ScopeIdentity, ScopeSelection, WikiError, compile as wiki_compile, daemon,
session, synthesis,
};
use super::lanes::{
ai_notice_label, notice_for_explainer_status, resolve_explainer_transport,
resolve_lane_b_generator, routing_label,
};
const COMMAND: &str = "gwiki compile";
#[allow(clippy::too_many_arguments)]
pub(crate) fn execute(
topic: Option<String>,
outline: Vec<String>,
source: Vec<String>,
target_kind: synthesis::ArticleKind,
target_page: Option<PathBuf>,
write_intent: bool,
ai: AiRouting,
scope: ScopeSelection,
) -> Result<CommandOutcome, WikiError> {
let resolved_scope = resolve_command_scope(&scope)?;
let research_scope = session::ResearchScope::from(&resolved_scope);
let topic_seed = compile_topic_seed(topic.as_deref(), &research_scope);
let mut session = load_compile_session(research_scope, topic_seed.as_deref())?;
if !source.is_empty() {
apply_source_selection(&mut session, &source)?;
} else {
reconcile_checkpoint_sources(&mut session)?;
}
let topic = resolve_compile_topic(topic_seed, &session);
let daemon_report = daemon::probe_daemon_capabilities();
let daemon_synthesis_available = daemon_report.synthesis.available;
let output_scope = resolved_scope_identity(&resolved_scope);
let vault_root = session.scope.root().to_path_buf();
let request = wiki_compile::CompileRequest {
topic,
outline: outline.clone(),
target_page,
write_intent,
};
let project_root = resolved_scope.project_root().map(|root| root.to_path_buf());
if let Some(mut lane_b) = resolve_lane_b_generator(
ai,
&scope,
vault_root,
project_root,
output_scope.clone(),
COMMAND,
) {
let info = lane_b.info;
let outcome = wiki_compile::compile_to_wiki_with_options(
&mut session,
request,
wiki_compile::WikiCompileOptions {
target_kind,
daemon_synthesis_available,
hard_fail_on_generation_failure: true,
..wiki_compile::WikiCompileOptions::default()
},
Some(lane_b.generator.as_mut()),
)?;
return Ok(compile_command_outcome(
ai,
"tool_loop",
info.route_label,
info.fallback,
info.notice,
&output_scope,
target_kind,
&outline,
daemon_synthesis_available,
outcome,
));
}
let transport = resolve_explainer_transport(ai, COMMAND);
let route_label = transport.route_label();
let notice = transport.notice_kind();
let fallback = transport.fallback();
let mut generate = |prompt: &ExplainerPrompt| transport.generate(prompt);
let generator: Option<ExplainerGenerator<'_>> = if transport.is_active() {
Some(&mut generate)
} else {
None
};
let outcome = wiki_compile::compile_to_wiki_with_options(
&mut session,
request,
wiki_compile::WikiCompileOptions {
target_kind,
daemon_synthesis_available,
hard_fail_on_generation_failure: false,
..wiki_compile::WikiCompileOptions::default()
},
generator,
)?;
Ok(compile_command_outcome(
ai,
"one_shot",
route_label,
fallback,
notice,
&output_scope,
target_kind,
&outline,
daemon_synthesis_available,
outcome,
))
}
#[allow(clippy::too_many_arguments)]
fn compile_command_outcome(
ai: AiRouting,
lane: &'static str,
route_label: &'static str,
fallback: bool,
notice: Option<AiNoticeKind>,
output_scope: &ScopeIdentity,
target_kind: synthesis::ArticleKind,
outline: &[String],
daemon_synthesis_available: bool,
outcome: wiki_compile::WikiCompileOutcome,
) -> CommandOutcome {
let explainer = outcome
.explainer
.clone()
.unwrap_or_else(ExplainerReport::skipped);
let notice = notice_for_explainer_status(explainer.status, notice);
let payload = serde_json::json!({
"command": "compile",
"scope": output_scope,
"status": "compiled",
"target_kind": target_kind,
"outline": outline,
"daemon_synthesis_available": daemon_synthesis_available,
"article_path": outcome.article_path,
"source_paths": outcome.source_paths,
"index_path": outcome.index_path,
"handoff_id": outcome.handoff_id,
"page_writes": outcome.page_writes,
"prompt": outcome.prompt,
"ai": {
"requested_mode": routing_label(ai),
"lane": lane,
"route": route_label,
"fallback": fallback,
"notice": notice.map(ai_notice_label),
"status": explainer.status,
"model": explainer.model,
"error": explainer.error,
"citations_kept": explainer.citations_kept,
"citations_stripped": explainer.citations_stripped,
"fallback_sections": explainer.fallback_sections,
},
});
let notice_text = notice
.map(|notice| format!("\nAI notice: {}", ai_notice_label(notice)))
.unwrap_or_default();
let text = format!(
"Compiled wiki article
Scope: {output_scope}
Article: {}{}",
outcome.article_path.display(),
notice_text
);
super::scoped_outcome("compile", output_scope, payload, text)
}
fn compile_topic_seed(
topic: Option<&str>,
research_scope: &session::ResearchScope,
) -> Option<String> {
topic.map(str::to_owned).or_else(|| match research_scope {
session::ResearchScope::Topic { name, .. } => Some(name.clone()),
_ => None,
})
}
fn load_compile_session(
research_scope: session::ResearchScope,
topic_seed: Option<&str>,
) -> Result<session::ResearchSession, WikiError> {
match session::ResearchSession::load_checkpoint(research_scope.root()) {
Ok(session) => Ok(session),
Err(WikiError::Io { action, source, .. })
if action == "read research checkpoint" && source.kind() == ErrorKind::NotFound =>
{
let Some(topic) = topic_seed else {
return Err(WikiError::InvalidInput {
field: "topic",
message: "compile requires TOPIC or --topic when no research checkpoint exists"
.to_string(),
});
};
session::ResearchSession::new(topic.to_string(), research_scope, Vec::new(), 1, None)
}
Err(error) => Err(error),
}
}
fn resolve_compile_topic(topic_seed: Option<String>, session: &session::ResearchSession) -> String {
topic_seed.unwrap_or_else(|| {
session
.compile_state
.as_ref()
.map(|state| state.topic.clone())
.unwrap_or_else(|| session.question.clone())
})
}
fn apply_source_selection(
session: &mut session::ResearchSession,
selectors: &[String],
) -> Result<(), WikiError> {
let manifest = SourceManifest::read(session.scope.root())?;
session.accepted_notes =
wiki_compile::select::resolve_source_notes(session.scope.root(), &manifest, selectors)?;
session.save_checkpoint()
}
fn reconcile_checkpoint_sources(session: &mut session::ResearchSession) -> Result<(), WikiError> {
let manifest = SourceManifest::read(session.scope.root())?;
let vault_root = session.scope.root().to_path_buf();
if wiki_compile::select::reconcile_accepted_notes_with_manifest(
&vault_root,
&mut session.accepted_notes,
&manifest,
) {
session.save_checkpoint()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths;
use crate::sources::{CompileStatus, IngestionMethod, SourceKind, SourceRecord};
fn manifest_source(id: &str, title: &str) -> SourceRecord {
SourceRecord {
id: id.to_string(),
location: format!("{id}.md"),
canonical_location: format!("file:///vault/{id}.md"),
kind: SourceKind::Markdown,
fetched_at: "2026-07-05T00:00:00Z".to_string(),
content_hash: format!("{id}-hash"),
title: Some(title.to_string()),
citation: None,
license: None,
ingestion_method: IngestionMethod::Manual,
compile_status: CompileStatus::Pending,
replay: None,
}
}
#[test]
fn missing_checkpoint_with_topic_seed_creates_fresh_compile_session() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = session::ResearchScope::project_for_id("project-1", temp.path());
let session =
load_compile_session(scope, Some("Fresh Topic")).expect("fresh compile session");
assert_eq!(session.question, "Fresh Topic");
assert!(session.accepted_notes.is_empty());
assert_eq!(session.scope.root(), temp.path());
}
#[test]
fn missing_checkpoint_without_topic_seed_requires_topic() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = session::ResearchScope::project_for_id("project-1", temp.path());
let error = load_compile_session(scope, None).expect_err("missing topic");
match error {
WikiError::InvalidInput { field, message } => {
assert_eq!(field, "topic");
assert_eq!(
message,
"compile requires TOPIC or --topic when no research checkpoint exists"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn explicit_topic_and_sources_compile_cited_page_without_checkpoint() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = session::ResearchScope::project_for_id("project-1", temp.path());
let checkpoint = session::ResearchSession::checkpoint_path(temp.path());
assert!(
!checkpoint.exists(),
"precondition: no research checkpoint at {}",
checkpoint.display()
);
let records = vec![
manifest_source("src-alpha", "Alpha Guide"),
manifest_source("src-beta", "Beta Notes"),
manifest_source("src-gamma", "Gamma Extra"),
];
for record in &records {
let raw = temp
.path()
.join(paths::raw_source_path(&record.id).expect("raw path"));
std::fs::create_dir_all(raw.parent().expect("raw parent")).expect("raw dir");
std::fs::write(
&raw,
format!(
"Citation: {} Reference\nEvidence chunk from {}.\n",
record.title.as_deref().expect("title"),
record.id
),
)
.expect("raw source written");
}
SourceManifest { entries: records }
.write(temp.path())
.expect("manifest written");
let mut session =
load_compile_session(scope, Some("Explicit Topic")).expect("fresh compile session");
apply_source_selection(
&mut session,
&["src-alpha".to_string(), "src-beta".to_string()],
)
.expect("source selection applied");
let topic = resolve_compile_topic(Some("Explicit Topic".to_string()), &session);
let outcome = wiki_compile::compile_to_wiki_with_options(
&mut session,
wiki_compile::CompileRequest {
topic,
outline: Vec::new(),
target_page: None,
write_intent: false,
},
wiki_compile::WikiCompileOptions::default(),
None,
)
.expect("compile succeeds without a pre-existing checkpoint");
let article = std::fs::read_to_string(&outcome.article_path).expect("article written");
assert!(
article.contains("Alpha Guide") && article.contains("Beta Notes"),
"article must cite both selected sources: {article}"
);
assert!(
!article.contains("Gamma"),
"unselected manifest source must not be cited: {article}"
);
assert_eq!(
outcome.source_paths.len(),
2,
"one digest page per selected source"
);
}
}