use std::collections::HashSet;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use gobby_core::ai::generation::{
ChatMessage, ChatTransport, DaemonChatTransport, DirectChatTransport, DirectGenerationTarget,
GenerationTier, ToolLoopLimits, generate_one_shot, profile_for_tier,
resolve_direct_generation_target, run_tool_loop,
};
use gobby_core::ai::{AiNoticeKind, resolve_route_observed};
use gobby_core::ai_context::{AiContext, AiContextOptions};
use gobby_core::config::{AiCapability, AiRouting};
use crate::explainer::{ExplainerGenerator, ExplainerPrompt, ExplainerReport, ExplainerResponse};
use crate::sources::{SourceManifest, SourceRecord};
use crate::support::scope::{resolve_command_scope, resolved_scope_identity};
use crate::{
CommandOutcome, ScopeIdentity, ScopeSelection, WikiError, compile as wiki_compile, daemon,
paths, session, synthesis,
};
use super::vault_tools::VaultToolExecutor;
#[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)?;
}
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,
};
if let Some(mut lane_b) =
resolve_compile_lane_b_generator(ai, &scope, vault_root, output_scope.clone())
{
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,
},
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);
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,
},
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)
}
type BoxedExplainerGenerator =
Box<dyn FnMut(&ExplainerPrompt) -> Result<ExplainerResponse, String>>;
struct CompileLaneB {
generator: BoxedExplainerGenerator,
info: LaneBInfo,
}
#[derive(Clone, Copy)]
struct LaneBInfo {
route_label: &'static str,
fallback: bool,
notice: Option<AiNoticeKind>,
}
fn resolve_compile_lane_b_generator(
requested: AiRouting,
scope: &ScopeSelection,
vault_root: PathBuf,
scope_identity: ScopeIdentity,
) -> Option<CompileLaneB> {
if matches!(requested, AiRouting::Off) {
return None;
}
let mut source = crate::support::config::hub_ai_config_source("gwiki compile").ok()?;
let context = AiContext::resolve_with_options(
None,
&mut source,
AiContextOptions {
no_ai: false,
forced_routing: Some(requested),
},
);
let observed = resolve_route_observed(&context, AiCapability::ToolChat);
let route = observed.route;
if matches!(route, AiRouting::Off | AiRouting::Auto) {
return None;
}
let profile = profile_for_tier(COMPILE_TIER, None);
let target = if matches!(route, AiRouting::Direct) {
let target =
resolve_direct_generation_target(&mut source, &profile_for_tier(COMPILE_TIER, None));
target.api_base()?;
Some(target)
} else {
None
};
let info = LaneBInfo {
route_label: routing_label(route),
fallback: observed.fallback,
notice: observed.reason.or_else(|| {
(observed.fallback && route == AiRouting::Direct)
.then_some(AiNoticeKind::AutoFallbackToDirect)
}),
};
let scope = scope.clone();
let generator: BoxedExplainerGenerator = Box::new(move |prompt: &ExplainerPrompt| {
run_compile_lane_b(
&context,
route,
&profile,
target.as_ref(),
prompt,
&scope,
&vault_root,
&scope_identity,
)
});
Some(CompileLaneB { generator, info })
}
#[allow(clippy::too_many_arguments)]
fn run_compile_lane_b(
context: &AiContext,
route: AiRouting,
profile: &str,
target: Option<&DirectGenerationTarget>,
prompt: &ExplainerPrompt,
scope: &ScopeSelection,
vault_root: &Path,
scope_identity: &ScopeIdentity,
) -> Result<ExplainerResponse, String> {
let mut executor = VaultToolExecutor::new(
scope.clone(),
vault_root.to_path_buf(),
scope_identity.clone(),
);
let messages = vec![
ChatMessage::system(prompt.system.to_string()),
ChatMessage::user(prompt.user.clone()),
];
let limits = ToolLoopLimits::default();
let (outcome, model) = match route {
AiRouting::Daemon => {
let transport = DaemonChatTransport::new(context, profile.to_string())
.map_err(|error| error.to_string())?;
let model = transport.model().map(str::to_string);
let outcome = run_tool_loop(&transport, &mut executor, messages, &limits, None)
.map_err(|error| error.to_string())?;
(outcome, model)
}
AiRouting::Direct => {
let target = target
.ok_or_else(|| "direct Lane B requires a resolved profile target".to_string())?;
let transport =
DirectChatTransport::new(context, target.clone(), Some(profile.to_string()))
.map_err(|error| error.to_string())?;
let model = transport.model().map(str::to_string);
let outcome = run_tool_loop(&transport, &mut executor, messages, &limits, None)
.map_err(|error| error.to_string())?;
(outcome, model)
}
AiRouting::Off | AiRouting::Auto => {
return Err("tool-chat route is off or unresolved".to_string());
}
};
let degraded = executor.into_data_source_degraded();
if !degraded.is_empty() {
log::warn!(
"compile Lane B: data-source degradation during tool loop: {}",
degraded.join(", ")
);
}
if !outcome.stop_reason.is_completed() {
return Err(format!(
"tool loop did not complete ({})",
outcome.stop_reason.as_str()
));
}
let content = outcome
.content
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| "tool loop returned no content".to_string())?;
Ok(ExplainerResponse {
text: content,
model,
route: routing_label(route),
})
}
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 = resolve_source_notes(session.scope.root(), &manifest, selectors)?;
session.save_checkpoint()
}
fn resolve_source_notes(
vault_root: &Path,
manifest: &SourceManifest,
selectors: &[String],
) -> Result<Vec<session::AcceptedResearchNote>, WikiError> {
let mut selected = Vec::new();
let mut seen = HashSet::new();
for selector in selectors {
let record = resolve_source_selector(manifest, selector)?;
if seen.insert(record.id.clone()) {
selected.push(accepted_note_from_source(vault_root, record)?);
}
}
Ok(selected)
}
fn resolve_source_selector<'a>(
manifest: &'a SourceManifest,
selector: &str,
) -> Result<&'a SourceRecord, WikiError> {
let selector = selector.trim();
if let Some(record) = manifest.entries.iter().find(|entry| entry.id == selector) {
return Ok(record);
}
let selector_path = Path::new(selector);
for record in &manifest.entries {
if paths::raw_source_path(&record.id)? == selector_path {
return Ok(record);
}
}
let matches = manifest
.entries
.iter()
.filter(|entry| entry.location == selector || entry.canonical_location == selector)
.collect::<Vec<_>>();
match matches.as_slice() {
[record] => Ok(record),
[] => Err(WikiError::NotFound {
resource: "source",
id: selector.to_string(),
}),
_ => Err(WikiError::InvalidInput {
field: "source",
message: format!(
"source selector `{selector}` matched multiple records; pass a source id"
),
}),
}
}
fn accepted_note_from_source(
vault_root: &Path,
record: &SourceRecord,
) -> Result<session::AcceptedResearchNote, WikiError> {
let raw_path = paths::raw_source_path(&record.id)?;
let absolute_path = vault_root.join(&raw_path);
match absolute_path.try_exists() {
Ok(true) => {}
Ok(false) => {
return Err(WikiError::NotFound {
resource: "raw_source",
id: raw_path.display().to_string(),
});
}
Err(error) => {
return Err(WikiError::Io {
action: "check raw source",
path: Some(absolute_path),
source: error,
});
}
}
Ok(session::AcceptedResearchNote {
title: record
.title
.clone()
.unwrap_or_else(|| record.location.clone()),
path: raw_path,
code_citations: Vec::new(),
degradation: None,
})
}
const COMPILE_TIER: GenerationTier = GenerationTier::Aggregate;
enum ExplainerTransport {
Off {
fallback: bool,
notice: Option<AiNoticeKind>,
},
Unresolved {
route: AiRouting,
fallback: bool,
notice: Option<AiNoticeKind>,
error: String,
},
Resolved {
route: AiRouting,
fallback: bool,
notice: Option<AiNoticeKind>,
context: Box<AiContext>,
target: Option<DirectGenerationTarget>,
},
}
impl ExplainerTransport {
fn off(fallback: bool, notice: Option<AiNoticeKind>) -> Self {
Self::Off { fallback, notice }
}
fn is_active(&self) -> bool {
!matches!(self, Self::Off { .. })
}
fn route_label(&self) -> &'static str {
match self {
Self::Off { .. } => "off",
Self::Unresolved { route, .. } | Self::Resolved { route, .. } => routing_label(*route),
}
}
fn fallback(&self) -> bool {
match self {
Self::Off { fallback, .. } => *fallback,
Self::Unresolved { fallback, .. } | Self::Resolved { fallback, .. } => *fallback,
}
}
fn notice_kind(&self) -> Option<AiNoticeKind> {
match self {
Self::Off { notice, .. } => *notice,
Self::Unresolved { notice, .. } | Self::Resolved { notice, .. } => *notice,
}
}
fn generate(&self, prompt: &ExplainerPrompt) -> Result<ExplainerResponse, String> {
match self {
Self::Off { .. } => Err("AI synthesis is off".to_string()),
Self::Unresolved { error, .. } => Err(error.clone()),
Self::Resolved {
route,
context,
target,
..
} => {
let result = generate_one_shot(
context,
*route,
COMPILE_TIER,
None,
target.as_ref(),
&prompt.user,
Some(prompt.system),
None,
)
.map_err(|error| error.to_string())?;
Ok(ExplainerResponse {
text: result.text,
model: result.model,
route: routing_label(*route),
})
}
}
}
}
fn resolve_explainer_transport(requested: AiRouting) -> ExplainerTransport {
if matches!(requested, AiRouting::Off) {
return ExplainerTransport::off(false, None);
}
match crate::support::config::hub_ai_config_source("gwiki compile") {
Ok(mut source) => {
let context = AiContext::resolve_with_options(
None,
&mut source,
AiContextOptions {
no_ai: false,
forced_routing: Some(requested),
},
);
let observed = resolve_route_observed(&context, AiCapability::TextGenerate);
match observed.route {
route @ (AiRouting::Daemon | AiRouting::Direct) => {
let target = matches!(route, AiRouting::Direct).then(|| {
resolve_direct_generation_target(
&mut source,
&profile_for_tier(COMPILE_TIER, None),
)
});
if target
.as_ref()
.is_some_and(|target| target.api_base().is_none())
{
return ExplainerTransport::Unresolved {
route,
fallback: observed.fallback,
notice: Some(AiNoticeKind::NoGenerator),
error: "direct AI synthesis requires ai.text_generate api_base"
.to_string(),
};
}
ExplainerTransport::Resolved {
route,
fallback: observed.fallback,
notice: observed.reason.or_else(|| {
(observed.fallback && route == AiRouting::Direct)
.then_some(AiNoticeKind::AutoFallbackToDirect)
}),
context: Box::new(context),
target,
}
}
_ => ExplainerTransport::off(observed.fallback, observed.reason),
}
}
Err(error) => match requested {
AiRouting::Daemon | AiRouting::Direct => ExplainerTransport::Unresolved {
route: requested,
fallback: false,
notice: Some(AiNoticeKind::NoGenerator),
error: error.to_string(),
},
AiRouting::Auto => ExplainerTransport::Unresolved {
route: AiRouting::Off,
fallback: true,
notice: Some(AiNoticeKind::NoGenerator),
error: error.to_string(),
},
_ => ExplainerTransport::off(false, None),
},
}
}
fn notice_for_explainer_status(status: &str, notice: Option<AiNoticeKind>) -> Option<AiNoticeKind> {
match (status, notice) {
("failed", None) => Some(AiNoticeKind::GenerationFailed),
(_, notice) => notice,
}
}
fn routing_label(route: AiRouting) -> &'static str {
match route {
AiRouting::Auto => "auto",
AiRouting::Daemon => "daemon",
AiRouting::Direct => "direct",
AiRouting::Off => "off",
}
}
fn ai_notice_label(notice: AiNoticeKind) -> &'static str {
match notice {
AiNoticeKind::AutoFallbackToDirect => "auto_fallback_to_direct",
AiNoticeKind::AutoFallbackToOff => "auto_fallback_to_off",
AiNoticeKind::NoGenerator => "no_generator",
AiNoticeKind::GenerationFailed => "generation_failed",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sources::{CompileStatus, IngestionMethod, SourceKind};
use gobby_core::ai::ObservedAiRoute;
use gobby_core::ai_context::{AiBindings, AiLimiter};
use gobby_core::config::{AiTuning, CapabilityBinding};
use std::fs;
#[test]
fn compile_generates_on_the_aggregate_feature_profile() {
use gobby_core::ai::generation::FEATURE_HIGH;
assert_eq!(COMPILE_TIER, GenerationTier::Aggregate);
assert_eq!(profile_for_tier(COMPILE_TIER, None), FEATURE_HIGH);
}
#[test]
fn observed_auto_daemon_up_ignores_direct_config() {
let context = ai_context(AiRouting::Auto, Some("http://direct.test"));
assert_eq!(
gobby_core::ai::resolve_route_observed_with_probe(
&context,
AiCapability::TextGenerate,
|_| true,
),
ObservedAiRoute {
route: AiRouting::Daemon,
fallback: false,
reason: None,
}
);
}
#[test]
fn observed_explicit_daemon_stays_daemon_when_probe_unavailable() {
let context = ai_context(AiRouting::Daemon, Some("http://direct.test"));
assert_eq!(
gobby_core::ai::resolve_route_observed_with_probe(
&context,
AiCapability::TextGenerate,
|_| false,
),
ObservedAiRoute {
route: AiRouting::Daemon,
fallback: false,
reason: None,
}
);
}
#[test]
fn off_transport_preserves_fallback_notice_metadata() {
let transport = ExplainerTransport::off(true, Some(AiNoticeKind::AutoFallbackToOff));
assert!(!transport.is_active());
assert_eq!(transport.route_label(), "off");
assert!(transport.fallback());
assert_eq!(
transport.notice_kind(),
Some(AiNoticeKind::AutoFallbackToOff)
);
}
#[test]
fn failed_explainer_status_preserves_existing_notice() {
assert_eq!(
notice_for_explainer_status("failed", Some(AiNoticeKind::NoGenerator)),
Some(AiNoticeKind::NoGenerator)
);
assert_eq!(
notice_for_explainer_status("failed", None),
Some(AiNoticeKind::GenerationFailed)
);
assert_eq!(
notice_for_explainer_status("generated", Some(AiNoticeKind::AutoFallbackToDirect)),
Some(AiNoticeKind::AutoFallbackToDirect)
);
}
fn ai_context(routing: AiRouting, api_base: Option<&str>) -> AiContext {
let binding = CapabilityBinding {
routing,
transport: None,
api_base: api_base.map(str::to_string),
api_key: None,
model: None,
provider: None,
task: None,
language: None,
target_lang: None,
profile: None,
candidates: None,
reasoning_effort: None,
verify_profile: None,
verify_model: None,
verify_api_key: None,
};
AiContext {
bindings: AiBindings {
embed: binding.clone(),
audio_transcribe: binding.clone(),
audio_translate: binding.clone(),
vision_extract: binding.clone(),
text_generate: binding,
},
tuning: AiTuning {
max_concurrency: 1,
keep_alive: None,
},
limiter: AiLimiter::new(1),
project_id: None,
}
}
fn source_record(
id: &str,
location: &str,
canonical_location: &str,
title: Option<&str>,
) -> SourceRecord {
SourceRecord {
id: id.to_string(),
location: location.to_string(),
canonical_location: canonical_location.to_string(),
kind: SourceKind::Markdown,
fetched_at: "2026-06-14T00:00:00Z".to_string(),
content_hash: format!("{id}-hash"),
title: title.map(str::to_string),
citation: None,
license: None,
ingestion_method: IngestionMethod::Manual,
compile_status: CompileStatus::Pending,
replay: None,
}
}
fn write_raw_source(root: &Path, record: &SourceRecord) {
let path = root.join(paths::raw_source_path(&record.id).expect("raw path"));
fs::create_dir_all(path.parent().expect("raw parent")).expect("create raw parent");
fs::write(&path, format!("# {}\n", record.id)).expect("write raw source");
}
#[test]
fn source_selectors_resolve_id_raw_path_location_and_canonical_location() {
let temp = tempfile::tempdir().expect("tempdir");
let records = vec![
source_record(
"src-alpha",
"alpha.md",
"file:///vault/alpha.md",
Some("Alpha"),
),
source_record("src-beta", "beta.md", "file:///vault/beta.md", Some("Beta")),
source_record(
"src-gamma",
"gamma.md",
"file:///vault/gamma.md",
Some("Gamma"),
),
source_record("src-delta", "delta.md", "canonical:delta", None),
];
for record in &records {
write_raw_source(temp.path(), record);
}
let manifest = SourceManifest { entries: records };
let notes = resolve_source_notes(
temp.path(),
&manifest,
&[
"src-alpha".to_string(),
"raw/src-beta.md".to_string(),
"gamma.md".to_string(),
"canonical:delta".to_string(),
],
)
.expect("source notes");
assert_eq!(
notes
.iter()
.map(|note| note.path.clone())
.collect::<Vec<_>>(),
vec![
PathBuf::from("raw/src-alpha.md"),
PathBuf::from("raw/src-beta.md"),
PathBuf::from("raw/src-gamma.md"),
PathBuf::from("raw/src-delta.md"),
]
);
assert_eq!(
notes
.iter()
.map(|note| note.title.as_str())
.collect::<Vec<_>>(),
vec!["Alpha", "Beta", "Gamma", "delta.md"]
);
}
#[test]
fn source_selection_dedupes_by_source_id_in_selector_order() {
let temp = tempfile::tempdir().expect("tempdir");
let alpha = source_record("src-alpha", "alpha.md", "canonical:alpha", Some("Alpha"));
let beta = source_record("src-beta", "beta.md", "canonical:beta", Some("Beta"));
write_raw_source(temp.path(), &alpha);
write_raw_source(temp.path(), &beta);
let manifest = SourceManifest {
entries: vec![alpha, beta],
};
let notes = resolve_source_notes(
temp.path(),
&manifest,
&[
"src-beta".to_string(),
"src-alpha".to_string(),
"raw/src-beta.md".to_string(),
"alpha.md".to_string(),
],
)
.expect("source notes");
assert_eq!(
notes
.iter()
.map(|note| note.path.clone())
.collect::<Vec<_>>(),
vec![
PathBuf::from("raw/src-beta.md"),
PathBuf::from("raw/src-alpha.md"),
]
);
}
#[test]
fn missing_source_selector_reports_source_not_found() {
let manifest = SourceManifest {
entries: vec![source_record(
"src-alpha",
"alpha.md",
"canonical:alpha",
Some("Alpha"),
)],
};
let error = resolve_source_selector(&manifest, "missing").expect_err("missing source");
match error {
WikiError::NotFound { resource, id } => {
assert_eq!(resource, "source");
assert_eq!(id, "missing");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn ambiguous_non_id_selector_reports_invalid_input() {
let manifest = SourceManifest {
entries: vec![
source_record("src-alpha", "shared.md", "canonical:alpha", Some("Alpha")),
source_record("src-beta", "shared.md", "canonical:beta", Some("Beta")),
],
};
let error = resolve_source_selector(&manifest, "shared.md").expect_err("ambiguous source");
match error {
WikiError::InvalidInput { field, message } => {
assert_eq!(field, "source");
assert_eq!(
message,
"source selector `shared.md` matched multiple records; pass a source id"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn missing_raw_file_for_selected_source_reports_raw_source_not_found() {
let temp = tempfile::tempdir().expect("tempdir");
let manifest = SourceManifest {
entries: vec![source_record(
"src-alpha",
"alpha.md",
"canonical:alpha",
Some("Alpha"),
)],
};
let error = resolve_source_notes(temp.path(), &manifest, &["src-alpha".to_string()])
.expect_err("missing raw source");
match error {
WikiError::NotFound { resource, id } => {
assert_eq!(resource, "raw_source");
assert_eq!(id, "raw/src-alpha.md");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[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:?}"),
}
}
}