use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use crate::capture_control::CaptureRegistry;
use async_trait::async_trait;
use chrono::Utc;
use clap::{Args as ClapArgs, ValueEnum};
use futures::stream::{self, Stream, StreamExt};
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
use scrybe_capture_mac::{input_devices, InputDevice, MacCapture, NativeMicCapture, SckCapture};
#[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
use scrybe_capture_mic::MicCapture;
#[cfg(feature = "mic-capture")]
use scrybe_core::capture::AudioCapture;
use scrybe_core::config::{
RecordConfig, SttConfig, RECORD_LLM_OPENAI_COMPAT, RECORD_LLM_STUB, RECORD_SOURCE_MIC,
RECORD_SOURCE_MIC_SYSTEM, RECORD_SOURCE_SYNTHETIC, RECORD_SYSTEM_BACKEND_SCK,
RECORD_SYSTEM_BACKEND_TAP,
};
use scrybe_core::context::MeetingContext;
use scrybe_core::diarize::Diarizer;
use scrybe_core::error::{CaptureError, CoreError, LlmError, SttError};
use scrybe_core::hooks::{Hook, LifecycleEvent};
use scrybe_core::notes_map_reduce::NotesRuntime;
use scrybe_core::pipeline::chunker::ChunkerConfig;
use scrybe_core::pipeline::vad::EnergyVad;
#[cfg(feature = "llm-openai-compat")]
use scrybe_core::providers::openai_compat_llm::OpenAiCompatLlmProvider;
#[cfg(feature = "stt-sherpa")]
use scrybe_core::providers::sherpa_streaming::{SherpaStreamingConfig, SherpaStreamingProvider};
use scrybe_core::providers::streaming::StreamingSttProvider;
#[cfg(feature = "whisper-local")]
use scrybe_core::providers::whisper_local::{WhisperLocalConfig, WhisperLocalProvider};
use scrybe_core::providers::{LlmProvider, SttProvider};
use scrybe_core::session::{
run_with_notes as run_session_with_notes, SessionInputs, SessionProgress,
};
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
use scrybe_core::storage::session_folder_name;
use scrybe_core::types::{
AttributedChunk, AudioChunk, AudioFrame, ConsentMode, FrameSource, SessionId, SpeakerLabel,
TranscriptChunk,
};
use tokio::sync::watch;
use crate::prompter::TtyPrompter;
use crate::runtime::{expand_root, load_or_default_config};
use scrybe_core::record_defaults;
#[derive(ClapArgs, Clone, Debug)]
pub struct Args {
#[arg(long)]
pub title: Option<String>,
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub yes: bool,
#[arg(long, value_enum)]
pub consent: Option<ConsentModeArg>,
#[arg(long, default_value_t = 5)]
pub synthetic_secs: u64,
#[arg(long, value_enum)]
pub source: Option<CaptureSourceArg>,
#[arg(long)]
pub input_device: Option<String>,
#[arg(long, value_enum)]
pub system_backend: Option<SystemBackendArg>,
#[arg(long, conflicts_with = "sherpa_model")]
pub whisper_model: Option<PathBuf>,
#[arg(long, conflicts_with = "whisper_model")]
pub sherpa_model: Option<PathBuf>,
#[arg(long, value_enum)]
pub llm: Option<LlmBackendArg>,
#[arg(long, default_value_t = false)]
pub shell: bool,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum ConsentModeArg {
Quick,
Notify,
Announce,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum CaptureSourceArg {
#[default]
Synthetic,
Mic,
#[value(name = "mic+system")]
MicSystem,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum SystemBackendArg {
#[default]
Sck,
Tap,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum LlmBackendArg {
#[default]
Stub,
#[value(name = "openai-compat")]
OpenAiCompat,
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
enum SystemCapture {
Sck(SckCapture),
Tap(MacCapture),
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
impl SystemCapture {
fn new(backend: SystemBackendArg) -> Self {
match backend {
SystemBackendArg::Sck => Self::Sck(SckCapture::new()),
SystemBackendArg::Tap => Self::Tap(MacCapture::new()),
}
}
fn start(&mut self) -> Result<()> {
match self {
Self::Sck(capture) => capture.start().map_err(Into::into),
Self::Tap(capture) => capture.start().map_err(Into::into),
}
}
fn frames(&self) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
match self {
Self::Sck(capture) => Box::pin(capture.frames()),
Self::Tap(capture) => Box::pin(capture.frames()),
}
}
fn stop(&mut self) -> Result<()> {
match self {
Self::Sck(capture) => capture.stop().map_err(Into::into),
Self::Tap(capture) => capture.stop().map_err(Into::into),
}
}
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const TAP_STARTUP_ACTIVITY_WINDOW: Duration = Duration::from_millis(1_500);
#[cfg(any(test, feature = "mic-capture"))]
type CaptureFrameStream = Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>;
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
async fn start_system_capture(
selected: SystemBackendArg,
) -> Result<(SystemCapture, CaptureFrameStream, Option<&'static str>)> {
let mut capture = SystemCapture::new(selected);
if let Err(error) = capture.start() {
let Some(backend) = fallback_backend(selected) else {
return Err(error);
};
let mut fallback = SystemCapture::new(backend);
fallback.start().context(
"Core Audio Tap failed to start and ScreenCaptureKit could not start either",
)?;
let frames = fallback.frames();
return Ok((
fallback,
frames,
Some("system capture switched from tap to sck after tap start failure"),
));
}
let frames = capture.frames();
if fallback_backend(selected).is_some() {
let (active, frames) = tap_produces_nonzero_frames(frames).await;
if !active {
capture
.stop()
.context("stopping silent Core Audio Tap before fallback")?;
let mut fallback = SystemCapture::new(SystemBackendArg::Sck);
fallback.start().context(
"Core Audio Tap had no startup activity and ScreenCaptureKit could not start",
)?;
let frames = fallback.frames();
return Ok((
fallback,
frames,
Some("system capture switched from tap to sck after no tap startup activity"),
));
}
return Ok((capture, frames, None));
}
Ok((capture, frames, None))
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
async fn tap_produces_nonzero_frames(mut frames: CaptureFrameStream) -> (bool, CaptureFrameStream) {
let deadline = tokio::time::Instant::now() + TAP_STARTUP_ACTIVITY_WINDOW;
let mut buffered = Vec::new();
let mut active = false;
loop {
match tokio::time::timeout_at(deadline, frames.next()).await {
Ok(Some(Ok(frame))) => {
active |= frame.samples.iter().any(|sample| *sample != 0.0);
buffered.push(Ok(frame));
if active {
break;
}
}
Ok(Some(Err(error))) => {
buffered.push(Err(error));
break;
}
Ok(None) | Err(_) => break,
}
}
(
active,
Box::pin(futures::stream::iter(buffered).chain(frames)),
)
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const fn fallback_backend(selected: SystemBackendArg) -> Option<SystemBackendArg> {
match selected {
SystemBackendArg::Tap => Some(SystemBackendArg::Sck),
SystemBackendArg::Sck => None,
}
}
impl From<ConsentModeArg> for ConsentMode {
fn from(value: ConsentModeArg) -> Self {
match value {
ConsentModeArg::Quick => Self::Quick,
ConsentModeArg::Notify => Self::Notify,
ConsentModeArg::Announce => Self::Announce,
}
}
}
pub async fn run(args: Args) -> Result<()> {
let (stop_tx, stop_rx) = watch::channel(false);
let signal_handle = tokio::spawn(monitor_signals(move || {
let _ = stop_tx.send(true);
}));
let result = run_with_stop(args, stop_rx).await;
signal_handle.abort();
result
}
#[cfg(feature = "mic-capture")]
fn start_registered_capture<T>(registry: &CaptureRegistry, capture: T) -> Result<CaptureFrameStream>
where
T: AudioCapture,
{
let capture = registry.register(capture);
let mut capture = capture
.lock()
.map_err(|_| anyhow::anyhow!("capture registry adapter mutex poisoned"))?;
capture.start()?;
Ok(Box::pin(capture.frames()))
}
#[allow(clippy::too_many_lines)]
pub async fn run_with_stop(args: Args, stop_rx: watch::Receiver<bool>) -> Result<()> {
let cfg = load_or_default_config()?;
let root = match &args.root {
Some(p) => expand_root(p),
None => expand_root(&cfg.storage.root),
};
tokio::fs::create_dir_all(&root)
.await
.with_context(|| format!("creating storage root {}", root.display()))?;
let auto_accept = args.yes || std::env::var("SCRYBE_CONSENT_AUTO_ACCEPT").as_deref() == Ok("1");
let prompter = TtyPrompter::new(auto_accept);
let source = resolve_capture_source(args.source, &cfg.record)?;
let stt_model = resolve_stt_model(
args.whisper_model.as_ref(),
args.sherpa_model.as_ref(),
&cfg.record,
&cfg.stt,
source,
);
let llm_backend = resolve_llm_backend(args.llm, &cfg.record)?;
let consent_mode = args.consent.map_or(cfg.consent.default_mode, Into::into);
let llm = build_llm_provider(llm_backend, &cfg.llm)?;
let notes_runtime = match llm_backend {
LlmBackendArg::Stub => None,
LlmBackendArg::OpenAiCompat => Some(NotesRuntime::load(&cfg.notes)?),
};
let system_backend = resolve_system_backend(args.system_backend, &cfg.record)?;
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
let _ = system_backend;
let diarizer = BinaryChannelDiarizer;
let hooks: Vec<Box<dyn Hook>> = Vec::new();
let id = SessionId::new();
let user = std::env::var("USER").unwrap_or_else(|_| "scrybe-user".into());
let started_at = Utc::now();
let capture_registry = CaptureRegistry::default();
let system_vad: Option<EnergyVad> = match source {
CaptureSourceArg::MicSystem => Some(EnergyVad::default()),
CaptureSourceArg::Synthetic | CaptureSourceArg::Mic => None,
};
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
let selected_input = match source {
CaptureSourceArg::Synthetic => None,
CaptureSourceArg::Mic | CaptureSourceArg::MicSystem => {
let requested = args
.input_device
.as_deref()
.or(cfg.record.input_device.as_deref());
let device = resolve_macos_input_device(requested)?;
eprintln!("scrybe: input: {} ({})", device.name, device.uid);
Some(device)
}
};
let registry_for_stop = capture_registry.clone();
let stop_future = Box::pin(async move {
wait_for_stop(stop_rx).await;
if let Err(error) = registry_for_stop.stop_all() {
tracing::error!(error = %error, "stopping registered capture failed");
}
});
let stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> = match source
{
CaptureSourceArg::Synthetic => {
Box::pin(synthetic_capture_stream(args.synthetic_secs).take_until(stop_future))
}
CaptureSourceArg::Mic => {
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
{
let device = selected_input
.as_ref()
.context("resolved microphone missing for mic capture")?;
let stream = start_registered_capture(
&capture_registry,
NativeMicCapture::new(device.uid.clone(), cfg.record.aec),
)
.with_context(|| {
format!(
"opening selected Core Audio input {} ({})",
device.name, device.uid
)
})?;
Box::pin(stream.take_until(stop_future))
}
#[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
{
if args.input_device.is_some() || cfg.record.input_device.is_some() {
anyhow::bail!(
"--input-device requires a macOS build with --features \
mic-capture,system-capture-mac"
);
}
let stream = start_registered_capture(&capture_registry, MicCapture::new())
.context(
"opening default input device (grant Microphone permission \
in System Settings → Privacy & Security if prompted)",
)?;
Box::pin(stream.take_until(stop_future))
}
#[cfg(not(feature = "mic-capture"))]
{
anyhow::bail!(
"--source mic requires the binary to be built with --features mic-capture; \
this binary was built without it"
);
}
}
CaptureSourceArg::MicSystem => {
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
{
use futures::stream;
let (mut system_capture, system_frames, fallback_note) =
start_system_capture(system_backend).await?;
if let Some(note) = fallback_note {
tracing::warn!(system_backend = "sck", "{note}");
write_capture_diagnostic(&root, started_at, id, args.title.as_deref(), note)?;
}
capture_registry.register_stopper(move || {
system_capture.stop().map_err(|error| {
CaptureError::Platform(Box::new(std::io::Error::other(error.to_string())))
})
});
let device = selected_input
.as_ref()
.context("resolved microphone missing for mic+system capture")?;
let mic_frames = start_registered_capture(
&capture_registry,
NativeMicCapture::new(device.uid.clone(), cfg.record.aec),
)
.with_context(|| {
format!(
"opening selected Core Audio input {} ({})",
device.name, device.uid
)
})?;
Box::pin(stream::select(mic_frames, system_frames).take_until(stop_future))
}
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
{
anyhow::bail!(
"--source mic+system requires the binary to be built with both \
--features mic-capture and --features system-capture-mac; \
this binary was built without one or both"
);
}
}
};
let stream = capture_liveness_watchdog(stream, capture_registry.clone());
let stt = match build_stt_provider(stt_model, &cfg.stt.language) {
Ok(stt) => stt,
Err(error) => {
if let Err(stop_error) = capture_registry.stop_all() {
tracing::error!(error = %stop_error, "stopping capture after STT initialization failure failed");
}
return Err(error);
}
};
let streaming_stt = stt.streaming();
let progress = |event| print_session_progress(event);
let outputs = run_session_with_notes(
SessionInputs {
id,
started_at,
root: root.clone(),
title: args.title.clone(),
user,
consent_mode,
context: MeetingContext {
title: args.title,
..MeetingContext::default()
},
mic_vad: EnergyVad::default(),
system_vad,
streaming_stt,
stt: &stt,
llm: &llm,
diarizer: &diarizer,
prompter: &prompter,
hooks: &hooks,
chunker_config: ChunkerConfig {
max_chunk: Duration::from_secs(30),
min_speech_before_silence_split: Duration::from_secs(5),
silence_split_after: Duration::from_secs(5),
},
verify_duration: !matches!(source, CaptureSourceArg::Synthetic),
progress: Some(&progress),
},
stream,
notes_runtime,
)
.await
.context("running session");
if let Err(error) = capture_registry.stop_all() {
tracing::error!(error = %error, "stopping capture after session completion failed");
}
let outputs = outputs?;
println!(
"scrybe record: session {} written to {}",
id,
outputs.folder.display()
);
println!(" transcript: {}", outputs.transcript_path.display());
println!(" notes: {}", outputs.notes_path.display());
println!(" meta: {}", outputs.meta_path.display());
if outputs.audio_path.exists() {
println!(" audio: {}", outputs.audio_path.display());
}
let playback_path = outputs.folder.join("playback.opus");
if playback_path.exists() {
println!(" playback: {}", playback_path.display());
}
Ok(())
}
fn print_session_progress(event: SessionProgress) {
match event {
SessionProgress::Recording => {
eprintln!("scrybe: recording; press Ctrl-C to stop");
}
SessionProgress::TranscriptAccepted(attributed) => {
let elapsed_secs = attributed.chunk.start_ms / 1_000;
let minutes = elapsed_secs / 60;
let seconds = elapsed_secs % 60;
let speaker = match &attributed.speaker {
SpeakerLabel::Me => "Me",
SpeakerLabel::Them => "Them",
SpeakerLabel::Named(name) => name,
SpeakerLabel::Unknown => "Unknown",
};
let text = attributed.chunk.text.trim();
if !text.is_empty() {
println!("[{minutes:02}:{seconds:02}] {speaker}: {text}");
}
}
SessionProgress::FinalizingTranscript { pending_chunks } => {
eprintln!(
"scrybe: finalizing transcript ({pending_chunks} pending chunk{})",
if pending_chunks == 1 { "" } else { "s" }
);
}
SessionProgress::EncodingAudio => {
eprintln!("scrybe: encoding audio artifacts");
}
SessionProgress::GeneratingNotes { groups } => {
eprintln!(
"scrybe: generating notes ({groups} request group{})",
if groups == 1 { "" } else { "s" }
);
}
SessionProgress::WritingMetadata => {
eprintln!("scrybe: writing session metadata");
}
}
}
const CAPTURE_LIVENESS_TIMEOUT: Duration = Duration::from_secs(30);
fn capture_liveness_watchdog(
stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
capture_registry: CaptureRegistry,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
capture_liveness_watchdog_with_timeout(stream, capture_registry, CAPTURE_LIVENESS_TIMEOUT)
}
fn capture_liveness_watchdog_with_timeout(
stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
capture_registry: CaptureRegistry,
timeout: Duration,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
Box::pin(stream::unfold(
(stream, capture_registry, false),
move |(mut stream, capture_registry, stopped)| async move {
if stopped {
return None;
}
match tokio::time::timeout(timeout, stream.next()).await {
Ok(Some(frame)) => Some((frame, (stream, capture_registry, false))),
Ok(None) => None,
Err(_) => {
if let Err(error) = capture_registry.stop_all() {
tracing::error!(error = %error, "stopping stalled capture failed");
}
let error = CaptureError::Platform(Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"capture liveness watchdog expired after 30 seconds without a frame",
)));
Some((Err(error), (stream, capture_registry, true)))
}
}
},
))
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
fn write_capture_diagnostic(
root: &std::path::Path,
started_at: chrono::DateTime<Utc>,
id: SessionId,
title: Option<&str>,
note: &str,
) -> Result<()> {
let folder = root.join(session_folder_name(
started_at,
title.unwrap_or("untitled"),
id,
));
std::fs::create_dir_all(&folder)
.with_context(|| format!("creating capture diagnostic folder {}", folder.display()))?;
std::fs::write(folder.join("capture.log"), format!("{note}\n"))
.context("writing system-capture fallback diagnostic")
}
fn resolve_capture_source(
explicit: Option<CaptureSourceArg>,
cfg: &RecordConfig,
) -> Result<CaptureSourceArg> {
if let Some(source) = explicit {
return Ok(source);
}
match cfg.validated_source() {
Some(RECORD_SOURCE_SYNTHETIC) => Ok(CaptureSourceArg::Synthetic),
Some(RECORD_SOURCE_MIC) => Ok(CaptureSourceArg::Mic),
Some(RECORD_SOURCE_MIC_SYSTEM) => Ok(CaptureSourceArg::MicSystem),
Some(_) | None => anyhow::bail!(
"invalid [record].source {}; expected one of: synthetic, mic, mic+system",
cfg.source
),
}
}
fn resolve_system_backend(
explicit: Option<SystemBackendArg>,
cfg: &RecordConfig,
) -> Result<SystemBackendArg> {
if let Some(backend) = explicit {
return Ok(backend);
}
match cfg.validated_system_backend() {
Some(RECORD_SYSTEM_BACKEND_SCK) => Ok(SystemBackendArg::Sck),
Some(RECORD_SYSTEM_BACKEND_TAP) => Ok(SystemBackendArg::Tap),
Some(_) | None => anyhow::bail!(
"invalid [record].system_backend {}; expected one of: sck, tap",
cfg.system_backend
),
}
}
fn resolve_llm_backend(
explicit: Option<LlmBackendArg>,
cfg: &RecordConfig,
) -> Result<LlmBackendArg> {
if let Some(backend) = explicit {
return Ok(backend);
}
match cfg.validated_llm() {
Some(RECORD_LLM_STUB) => Ok(LlmBackendArg::Stub),
Some(RECORD_LLM_OPENAI_COMPAT) => Ok(LlmBackendArg::OpenAiCompat),
Some(_) | None => anyhow::bail!(
"invalid [record].llm {}; expected one of: stub, openai-compat",
cfg.llm
),
}
}
enum SttModel {
Stub,
Whisper(PathBuf),
Sherpa(PathBuf),
}
fn resolve_stt_model(
whisper_model: Option<&PathBuf>,
sherpa_model: Option<&PathBuf>,
record: &RecordConfig,
stt: &SttConfig,
source: CaptureSourceArg,
) -> SttModel {
if let Some(path) = sherpa_model {
return SttModel::Sherpa(path.clone());
}
if let Some(path) = whisper_model {
return SttModel::Whisper(path.clone());
}
if let Some(path) = &record.whisper_model {
return SttModel::Whisper(expand_root(path));
}
if !matches!(source, CaptureSourceArg::Synthetic) && stt.provider == "whisper-local" {
return record_defaults::whisper_model_path(&stt.model)
.map_or(SttModel::Stub, SttModel::Whisper);
}
SttModel::Stub
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
fn resolve_macos_input_device(requested_uid: Option<&str>) -> Result<InputDevice> {
let devices = input_devices()
.map_err(anyhow::Error::from)
.context("enumerating macOS Core Audio input devices")?;
if let Some(uid) = requested_uid {
return devices
.into_iter()
.find(|device| device.uid == uid)
.with_context(|| format!("configured Core Audio input device `{uid}` was not found"));
}
devices
.into_iter()
.find(|device| device.is_default)
.context("macOS has no default Core Audio input device")
}
async fn wait_for_stop(mut stop_rx: watch::Receiver<bool>) {
let _ = stop_rx.wait_for(|stopped| *stopped).await;
}
pub async fn monitor_signals<F>(mut request_graceful_stop: F)
where
F: FnMut() + Send + 'static,
{
#[cfg(unix)]
let Ok(mut sigterm) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) else {
tracing::error!("installing SIGTERM listener failed");
return;
};
let mut graceful_requested = false;
loop {
#[cfg(unix)]
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
#[cfg(not(unix))]
if tokio::signal::ctrl_c().await.is_err() {
return;
}
if graceful_requested {
std::process::exit(130);
}
graceful_requested = true;
request_graceful_stop();
}
}
#[allow(clippy::cast_precision_loss)]
fn synthetic_capture_stream(
seconds: u64,
) -> impl Stream<Item = Result<AudioFrame, scrybe_core::error::CaptureError>> + Send + Unpin {
const SAMPLE_RATE: u32 = 16_000;
const FRAME_SAMPLES: usize = 1_600;
let total_speech = seconds * (u64::from(SAMPLE_RATE) / FRAME_SAMPLES as u64);
let total_silence = (u64::from(SAMPLE_RATE) / FRAME_SAMPLES as u64) * 6;
let total = total_speech + total_silence;
let frame_delay = synthetic_frame_delay();
Box::pin(stream::iter(0..total).then(move |i| async move {
if !frame_delay.is_zero() {
tokio::time::sleep(frame_delay).await;
}
let speech = i < total_speech;
let samples: Vec<f32> = (0..FRAME_SAMPLES)
.map(|n| {
if speech {
let t = (i * FRAME_SAMPLES as u64 + n as u64) as f32 / SAMPLE_RATE as f32;
(t * 440.0 * std::f32::consts::TAU).sin()
} else {
0.0
}
})
.collect();
let timestamp_ns = (i * FRAME_SAMPLES as u64 * 1_000_000_000) / u64::from(SAMPLE_RATE);
Ok(AudioFrame {
samples: Arc::from(samples),
channels: 1,
sample_rate: SAMPLE_RATE,
timestamp_ns,
source: FrameSource::Mic,
})
}))
}
fn synthetic_frame_delay() -> Duration {
std::env::var("SCRYBE_TEST_SYNTHETIC_FRAME_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map_or(Duration::ZERO, Duration::from_millis)
}
enum CliStt {
Stub(StubLocalStt),
#[cfg(feature = "stt-sherpa")]
Sherpa(SherpaStreamingProvider),
#[cfg(feature = "whisper-local")]
Whisper(WhisperLocalProvider),
}
impl CliStt {
fn streaming(&self) -> Option<&dyn StreamingSttProvider> {
match self {
#[cfg(feature = "stt-sherpa")]
Self::Sherpa(provider) => Some(provider),
#[cfg(feature = "whisper-local")]
Self::Whisper(_) => None,
Self::Stub(_) => None,
}
}
}
#[async_trait]
impl SttProvider for CliStt {
async fn transcribe(&self, chunk: AudioChunk) -> Result<TranscriptChunk, SttError> {
match self {
Self::Stub(s) => s.transcribe(chunk).await,
#[cfg(feature = "stt-sherpa")]
Self::Sherpa(provider) => provider.transcribe(chunk).await,
#[cfg(feature = "whisper-local")]
Self::Whisper(provider) => provider.transcribe(chunk).await,
}
}
fn name(&self) -> &str {
match self {
Self::Stub(s) => s.name(),
#[cfg(feature = "stt-sherpa")]
Self::Sherpa(provider) => SttProvider::name(provider),
#[cfg(feature = "whisper-local")]
Self::Whisper(provider) => provider.name(),
}
}
}
#[allow(unused_variables)]
fn build_stt_provider(model: SttModel, language: &str) -> Result<CliStt> {
match model {
SttModel::Stub => Ok(CliStt::Stub(StubLocalStt::new())),
SttModel::Whisper(path) => {
#[cfg(feature = "whisper-local")]
{
let mut config = WhisperLocalConfig::new(path.clone());
config.language = language.to_string();
let provider = WhisperLocalProvider::new(config)
.with_context(|| format!("loading whisper.cpp model at {}", path.display()))?;
Ok(CliStt::Whisper(provider))
}
#[cfg(not(feature = "whisper-local"))]
{
anyhow::bail!(
"--whisper-model {} provided but binary built without --features whisper-local; \
rebuild with `cargo install --features whisper-local,...` or remove the flag",
path.display()
);
}
}
SttModel::Sherpa(path) => {
#[cfg(feature = "stt-sherpa")]
{
let provider =
SherpaStreamingProvider::new(SherpaStreamingConfig::new(path.clone()))
.with_context(|| {
format!("loading streaming Sherpa-ONNX model at {}", path.display())
})?;
Ok(CliStt::Sherpa(provider))
}
#[cfg(not(feature = "stt-sherpa"))]
{
anyhow::bail!(
"--sherpa-model {} provided but binary built without --features stt-sherpa; \
rebuild with `cargo install --features stt-sherpa,...` or remove the flag",
path.display()
);
}
}
}
}
struct StubLocalStt;
impl StubLocalStt {
const fn new() -> Self {
Self
}
}
#[async_trait]
impl SttProvider for StubLocalStt {
async fn transcribe(&self, chunk: AudioChunk) -> Result<TranscriptChunk, SttError> {
let speech = chunk.samples.iter().any(|s| s.abs() > 0.01);
let text = if speech {
"[synthetic speech chunk; build with --features whisper-local for real transcription]"
} else {
"[silence]"
};
Ok(TranscriptChunk {
text: text.to_string(),
source: chunk.source,
start_ms: u64::try_from(chunk.start.as_millis()).unwrap_or(0),
duration_ms: u64::try_from(chunk.duration.as_millis()).unwrap_or(0),
language: None,
tokens: Vec::new(),
})
}
fn name(&self) -> &'static str {
"stub-local-stt"
}
}
enum CliLlm {
Stub(StubLocalLlm),
#[cfg(feature = "llm-openai-compat")]
OpenAiCompat(OpenAiCompatLlmProvider),
}
#[async_trait]
impl LlmProvider for CliLlm {
async fn complete(&self, prompt: &str) -> Result<String, LlmError> {
match self {
Self::Stub(p) => p.complete(prompt).await,
#[cfg(feature = "llm-openai-compat")]
Self::OpenAiCompat(p) => p.complete(prompt).await,
}
}
fn name(&self) -> &str {
match self {
Self::Stub(p) => p.name(),
#[cfg(feature = "llm-openai-compat")]
Self::OpenAiCompat(p) => p.name(),
}
}
}
#[allow(unused_variables)]
fn build_llm_provider(
backend: LlmBackendArg,
cfg: &scrybe_core::config::LlmConfig,
) -> Result<CliLlm> {
match backend {
LlmBackendArg::Stub => Ok(CliLlm::Stub(StubLocalLlm::new())),
LlmBackendArg::OpenAiCompat => {
#[cfg(feature = "llm-openai-compat")]
{
let provider = OpenAiCompatLlmProvider::from_config(cfg)
.context("constructing OpenAI-compat LLM provider from [llm] config")?;
Ok(CliLlm::OpenAiCompat(provider))
}
#[cfg(not(feature = "llm-openai-compat"))]
{
anyhow::bail!(
"--llm openai-compat requires the binary to be built with \
--features llm-openai-compat; rebuild with \
`cargo install --features llm-openai-compat,...` or pass \
--llm stub"
);
}
}
}
}
struct StubLocalLlm;
impl StubLocalLlm {
const fn new() -> Self {
Self
}
}
#[async_trait]
impl LlmProvider for StubLocalLlm {
async fn complete(&self, prompt: &str) -> Result<String, LlmError> {
if prompt.starts_with("Create a short, factual title") {
return Ok("Synthetic Stub Session".to_string());
}
Ok(
"## TL;DR\nSynthetic stub session. Build with a configured LLM \
provider to generate real notes.\n## Action items\n- (none)\n\
## Decisions\n- (none)\n## Follow-ups\n- (none)\n"
.to_string(),
)
}
fn name(&self) -> &'static str {
"stub-local-llm"
}
}
struct BinaryChannelDiarizer;
#[async_trait]
impl Diarizer for BinaryChannelDiarizer {
async fn diarize(
&self,
mic: &[TranscriptChunk],
sys: &[TranscriptChunk],
_ctx: &MeetingContext,
) -> Result<Vec<AttributedChunk>, CoreError> {
let mut out = Vec::with_capacity(mic.len() + sys.len());
for chunk in mic {
out.push(AttributedChunk {
chunk: chunk.clone(),
speaker: SpeakerLabel::Me,
});
}
for chunk in sys {
out.push(AttributedChunk {
chunk: chunk.clone(),
speaker: SpeakerLabel::Them,
});
}
Ok(out)
}
fn name(&self) -> &'static str {
"binary-channel"
}
}
#[allow(dead_code)]
const fn _ensure_event_dispatch_compiles(_event: &LifecycleEvent) {}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_capture_liveness_watchdog_stops_adapters_and_reports_timeout() {
let registry = CaptureRegistry::default();
let stops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_stops = Arc::clone(&stops);
registry.register_stopper(move || {
observed_stops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
let stalled = Box::pin(stream::pending::<Result<AudioFrame, CaptureError>>());
let mut watchdog =
capture_liveness_watchdog_with_timeout(stalled, registry, Duration::from_millis(10));
let error = watchdog
.next()
.await
.expect("watchdog error")
.expect_err("timeout error");
assert_eq!(error.to_string(), "platform API error: capture liveness watchdog expired after 30 seconds without a frame");
assert_eq!(stops.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(watchdog.next().await.is_none());
}
#[test]
fn test_consent_mode_arg_quick_maps_to_consent_mode_quick() {
let mode: ConsentMode = ConsentModeArg::Quick.into();
assert_eq!(mode, ConsentMode::Quick);
}
#[test]
fn test_consent_mode_arg_notify_maps_to_consent_mode_notify() {
let mode: ConsentMode = ConsentModeArg::Notify.into();
assert_eq!(mode, ConsentMode::Notify);
}
#[test]
fn test_consent_mode_arg_announce_maps_to_consent_mode_announce() {
let mode: ConsentMode = ConsentModeArg::Announce.into();
assert_eq!(mode, ConsentMode::Announce);
}
#[tokio::test]
async fn test_synthetic_capture_stream_emits_speech_then_silence_frames() {
let stream = synthetic_capture_stream(1);
let frames: Vec<_> = stream.collect().await;
assert!(!frames.is_empty());
let speech_count = frames
.iter()
.filter(|f| {
f.as_ref()
.is_ok_and(|frame| frame.samples.iter().any(|s| s.abs() > 0.01))
})
.count();
assert!(
speech_count >= 5,
"expected speech frames; got {speech_count}"
);
}
#[tokio::test]
async fn test_stub_local_stt_returns_speech_marker_for_non_silence_chunk() {
let pcm: Arc<[f32]> = Arc::from(vec![0.5_f32; 16_000]);
let chunk = AudioChunk {
samples: pcm,
source: FrameSource::Mic,
start: Duration::ZERO,
duration: Duration::from_secs(1),
};
let result = StubLocalStt::new().transcribe(chunk).await.unwrap();
assert!(result.text.contains("synthetic speech"));
}
#[tokio::test]
async fn test_stub_local_stt_returns_silence_marker_for_zero_buffer() {
let pcm: Arc<[f32]> = Arc::from(vec![0.0_f32; 16_000]);
let chunk = AudioChunk {
samples: pcm,
source: FrameSource::Mic,
start: Duration::ZERO,
duration: Duration::from_secs(1),
};
let result = StubLocalStt::new().transcribe(chunk).await.unwrap();
assert_eq!(result.text, "[silence]");
}
#[tokio::test]
async fn test_stub_local_llm_returns_template_notes_body() {
let llm = StubLocalLlm::new();
let body = llm.complete("any prompt").await.unwrap();
assert!(body.contains("## TL;DR"));
assert!(body.contains("## Action items"));
}
#[tokio::test]
async fn test_binary_channel_diarizer_labels_mic_as_me_and_system_as_them() {
let mic = vec![TranscriptChunk {
text: "hi".into(),
source: FrameSource::Mic,
start_ms: 0,
duration_ms: 1_000,
language: None,
tokens: Vec::new(),
}];
let sys = vec![TranscriptChunk {
text: "hello".into(),
source: FrameSource::System,
start_ms: 0,
duration_ms: 1_000,
language: None,
tokens: Vec::new(),
}];
let result = BinaryChannelDiarizer
.diarize(&mic, &sys, &MeetingContext::default())
.await
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].speaker, SpeakerLabel::Me);
assert_eq!(result[1].speaker, SpeakerLabel::Them);
}
#[test]
fn test_binary_channel_diarizer_name_returns_binary_channel() {
assert_eq!(BinaryChannelDiarizer.name(), "binary-channel");
}
#[test]
fn test_stub_local_stt_name_returns_stub_local_stt() {
assert_eq!(StubLocalStt::new().name(), "stub-local-stt");
}
#[test]
fn test_stub_local_llm_name_returns_stub_local_llm() {
assert_eq!(StubLocalLlm::new().name(), "stub-local-llm");
}
#[tokio::test]
async fn test_run_writes_session_artifacts_for_synthetic_capture() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
run(Args {
title: Some("synthetic".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await
.unwrap();
let mut entries = std::fs::read_dir(dir.path()).unwrap();
let session = entries
.next()
.expect("a session folder must exist")
.unwrap();
assert!(session.path().join("transcript.md").exists());
assert!(session.path().join("notes.md").exists());
assert!(session.path().join("meta.toml").exists());
}
#[tokio::test]
async fn test_wait_for_stop_resolves_when_sender_flips_to_true() {
let (tx, rx) = watch::channel(false);
let fut = wait_for_stop(rx);
tokio::pin!(fut);
assert!(
futures::poll!(&mut fut).is_pending(),
"wait_for_stop must remain pending while the flag is false"
);
tx.send(true).unwrap();
fut.await;
}
#[tokio::test]
async fn test_wait_for_stop_returns_immediately_when_sender_already_true() {
let (_tx, rx) = watch::channel(true);
wait_for_stop(rx).await;
}
#[tokio::test]
async fn test_wait_for_stop_resolves_when_sender_dropped() {
let (tx, rx) = watch::channel(false);
drop(tx);
wait_for_stop(rx).await;
}
#[tokio::test]
async fn test_run_auto_accepts_consent_via_env_var_when_yes_flag_is_false() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
let dir = tempfile::tempdir().unwrap();
let result = run(Args {
title: Some("env-consent".into()),
root: Some(dir.path().to_path_buf()),
yes: false,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await;
std::env::remove_var("SCRYBE_CONSENT_AUTO_ACCEPT");
result.unwrap();
}
#[tokio::test]
async fn test_synthetic_capture_stream_emits_only_silence_for_zero_seconds() {
let stream = synthetic_capture_stream(0);
let frames: Vec<_> = stream.collect().await;
let speech_count = frames
.iter()
.filter(|f| {
f.as_ref()
.is_ok_and(|frame| frame.samples.iter().any(|s| s.abs() > 0.01))
})
.count();
assert_eq!(speech_count, 0);
}
#[tokio::test]
async fn test_run_completes_within_cold_start_budget_with_stub_providers() {
const COLD_START_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
let started = std::time::Instant::now();
run(Args {
title: Some("cold-start".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await
.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed < COLD_START_BUDGET,
"cold-start exceeded {COLD_START_BUDGET:?}: actual {elapsed:?} \
— the stub-provider path should complete sub-second; investigate \
before bumping this budget"
);
}
#[test]
fn test_capture_source_arg_default_is_synthetic() {
assert_eq!(CaptureSourceArg::default(), CaptureSourceArg::Synthetic);
}
#[test]
fn test_capture_source_arg_parses_mic_plus_system_token() {
use clap::ValueEnum;
let arg = CaptureSourceArg::from_str("mic+system", false)
.expect("`mic+system` must parse to MicSystem");
assert_eq!(arg, CaptureSourceArg::MicSystem);
}
#[test]
fn test_capture_source_arg_rejects_typo_variants() {
use clap::ValueEnum;
for bad in ["mic-system", "mic_system", "system", "system+mic"] {
let r = CaptureSourceArg::from_str(bad, false);
assert!(r.is_err(), "{bad} must not parse to any variant; got {r:?}");
}
}
#[test]
fn test_system_backend_flag_overrides_record_config() {
let cfg = RecordConfig {
system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
..RecordConfig::default()
};
assert_eq!(
resolve_system_backend(Some(SystemBackendArg::Sck), &cfg).unwrap(),
SystemBackendArg::Sck
);
}
#[test]
fn test_system_backend_uses_valid_record_config_then_default() {
let tap_cfg = RecordConfig {
system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
..RecordConfig::default()
};
assert_eq!(
resolve_system_backend(None, &tap_cfg).unwrap(),
SystemBackendArg::Tap
);
assert_eq!(
resolve_system_backend(None, &RecordConfig::default()).unwrap(),
SystemBackendArg::Sck
);
}
#[test]
fn test_tap_fallback_is_single_hop_to_sck() {
assert_eq!(
fallback_backend(SystemBackendArg::Tap),
Some(SystemBackendArg::Sck)
);
assert_eq!(fallback_backend(SystemBackendArg::Sck), None);
}
fn system_frame(samples: &[f32], timestamp_ns: u64) -> AudioFrame {
AudioFrame::from_slice(samples, 1, 16_000, timestamp_ns, FrameSource::System)
}
#[tokio::test]
async fn test_silent_tap_startup_falls_back_without_dropping_frames() {
let input = vec![
Ok(system_frame(&[0.0, 0.0], 0)),
Ok(system_frame(&[0.0, 0.0], 125_000)),
];
let (active, frames) =
tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
let observed: Vec<_> = frames
.map(|frame| {
let frame = frame.unwrap();
(frame.timestamp_ns, frame.samples.to_vec())
})
.collect()
.await;
assert!(!active);
assert_eq!(
observed,
vec![(0, vec![0.0, 0.0]), (125_000, vec![0.0, 0.0])]
);
}
#[tokio::test]
async fn test_active_tap_startup_preserves_buffered_and_remaining_frames() {
let input = vec![
Ok(system_frame(&[0.0, 0.0], 0)),
Ok(system_frame(&[0.25, 0.0], 125_000)),
Ok(system_frame(&[0.5, 0.0], 250_000)),
];
let (active, frames) =
tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
let observed: Vec<_> = frames
.map(|frame| {
let frame = frame.unwrap();
(frame.timestamp_ns, frame.samples.to_vec())
})
.collect()
.await;
assert!(active);
assert_eq!(
observed,
vec![
(0, vec![0.0, 0.0]),
(125_000, vec![0.25, 0.0]),
(250_000, vec![0.5, 0.0]),
]
);
}
#[test]
fn test_fallback_diagnostic_uses_initial_session_folder() {
let root = tempfile::tempdir().unwrap();
let started_at = Utc::now();
let id = SessionId::new();
write_capture_diagnostic(
root.path(),
started_at,
id,
Some("Initial title"),
"system capture switched from tap to sck after no tap startup activity",
)
.unwrap();
let folder = root
.path()
.join(session_folder_name(started_at, "Initial title", id));
assert_eq!(
std::fs::read_to_string(folder.join("capture.log")).unwrap(),
"system capture switched from tap to sck after no tap startup activity\n"
);
}
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
#[tokio::test]
async fn test_run_with_mic_system_source_errors_without_both_features() {
std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
let dir = tempfile::tempdir().unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
let result = run(Args {
title: Some("ms-feature-gate".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::MicSystem),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
whisper_model: None,
sherpa_model: None,
input_device: None,
})
.await;
let Err(err) = result else {
panic!("MicSystem without both features must error");
};
let msg = format!("{err:?}");
assert!(
msg.contains("--source mic+system")
&& msg.contains("mic-capture")
&& msg.contains("system-capture-mac"),
"error must name the source flag and both required features; got: {msg}"
);
}
#[test]
fn test_build_stt_provider_returns_stub_when_no_model_path_supplied() {
let stt = build_stt_provider(SttModel::Stub, "en").expect("stub branch must succeed");
assert_eq!(stt.name(), "stub-local-stt");
}
#[test]
fn test_stub_provider_exposes_no_streaming_capability() {
let stt = build_stt_provider(SttModel::Stub, "en").expect("stub branch must succeed");
assert!(stt.streaming().is_none());
}
#[cfg(not(feature = "whisper-local"))]
#[test]
fn test_build_stt_provider_errors_when_whisper_model_supplied_without_feature() {
let result = build_stt_provider(
SttModel::Whisper(PathBuf::from("/tmp/no-such-model.bin")),
"en",
);
let Err(err) = result else {
panic!("flag without feature must error rather than silently stub");
};
let message = format!("{err:?}");
assert!(
message.contains("--whisper-model") && message.contains("--features whisper-local"),
"error must name both the flag and the missing feature; got: {message}"
);
}
#[cfg(not(feature = "stt-sherpa"))]
#[test]
fn test_build_stt_provider_errors_when_sherpa_model_supplied_without_feature() {
let result =
build_stt_provider(SttModel::Sherpa(PathBuf::from("/tmp/no-such-model")), "en");
let Err(err) = result else {
panic!("flag without feature must error rather than silently stub");
};
let message = format!("{err:?}");
assert!(
message.contains("--sherpa-model") && message.contains("--features stt-sherpa"),
"error must name both the flag and the missing feature; got: {message}"
);
}
#[test]
fn test_explicit_sherpa_model_overrides_configured_whisper_model() {
let config = RecordConfig {
whisper_model: Some(PathBuf::from("/models/whisper.bin")),
..RecordConfig::default()
};
let model = resolve_stt_model(
None,
Some(&PathBuf::from("/models/sherpa")),
&config,
&SttConfig::default(),
CaptureSourceArg::Mic,
);
assert!(
matches!(model, SttModel::Sherpa(path) if path.as_path() == std::path::Path::new("/models/sherpa"))
);
}
#[cfg(feature = "whisper-local")]
#[test]
fn test_build_stt_provider_rejects_partial_whisper_model_path() {
let dir = tempfile::tempdir().unwrap();
let partial = dir.path().join("ggml-tiny.bin.partial");
std::fs::write(&partial, b"unfinished download").unwrap();
let result = build_stt_provider(SttModel::Whisper(partial), "en");
let Err(err) = result else {
panic!("partial paths must be rejected at construction");
};
let message = format!("{err:?}");
assert!(
message.contains("loading whisper.cpp model"),
"context chain must mention the loading step; got: {message}"
);
}
#[test]
fn test_build_llm_provider_returns_stub_when_backend_is_stub() {
let cfg = scrybe_core::config::LlmConfig::default();
let llm = build_llm_provider(LlmBackendArg::Stub, &cfg)
.expect("stub branch must succeed regardless of features");
assert_eq!(llm.name(), "stub-local-llm");
}
#[cfg(not(feature = "llm-openai-compat"))]
#[test]
fn test_build_llm_provider_errors_when_openai_compat_requested_without_feature() {
let cfg = scrybe_core::config::LlmConfig::default();
let result = build_llm_provider(LlmBackendArg::OpenAiCompat, &cfg);
let Err(err) = result else {
panic!("openai-compat without feature must error rather than silently stub");
};
let msg = format!("{err:?}");
assert!(
msg.contains("--llm openai-compat") && msg.contains("--features llm-openai-compat"),
"error must name both the flag and the missing feature; got: {msg}"
);
}
#[cfg(feature = "llm-openai-compat")]
#[test]
fn test_build_llm_provider_constructs_openai_compat_when_feature_enabled() {
let cfg = scrybe_core::config::LlmConfig {
provider: "ollama".into(),
model: "llama3.1:8b".into(),
..scrybe_core::config::LlmConfig::default()
};
let llm = build_llm_provider(LlmBackendArg::OpenAiCompat, &cfg)
.expect("openai-compat branch must succeed when feature is on");
assert_eq!(llm.name(), "ollama:llama3.1:8b");
}
#[test]
fn test_llm_backend_arg_default_is_stub() {
assert_eq!(LlmBackendArg::default(), LlmBackendArg::Stub);
}
}