p0-1: kb-app facade stubs + tracing init helper

Adds the kb-app crate (§7) as the single facade between UI crates
(kb-cli / kb-tui / kb-desktop) and the rest of the workspace. Public
surface mirrors the task spec exactly:

- init_workspace(force) — XDG dir creation + config.toml seed; idempotent
  unless force=true. Honors XDG envs and tilde-expands the workspace
  root to $HOME/KnowledgeBase.
- doctor() — emits a doctor.v1 report with config_loaded +
  data_dir_writable checks; downstream checks land in later phases.
- ingest / list_docs / inspect_doc / inspect_chunk / search / ask —
  bail!("not yet wired (P<n>-<i>)") so kb-cli surfaces exit code 2
  cleanly per §10.
- AskOpts + DoctorReport + DoctorCheck.
- doctor_signal::{DoctorUnhealthy, RefusalSignal, NoHitSignal} —
  signal types the CLI downcasts on for §10 exit-code mapping.
- logging::init() — daily-rolling file appender at
  $XDG_STATE_HOME/kb/logs/kb.log, plus stderr-fallback EnvFilter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 05:17:11 +00:00
parent 76a860296e
commit 237ada6e21
4 changed files with 275 additions and 0 deletions

21
crates/kb-app/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "kb-app"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Facade — orchestrates components for kb-cli/tui/desktop"
[dependencies]
kb-core = { path = "../kb-core" }
kb-config = { path = "../kb-config" }
anyhow = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
tracing-appender = "0.2"
toml = "0.8"
dirs = "5"

View File

@@ -0,0 +1,39 @@
//! Signal types used by `kb-cli`'s `exit_code` mapping (§10).
//!
//! These are *not* errors per se: a doctor failure is normal output, just
//! signalled out-of-band so the CLI can exit with the right status.
use std::fmt;
#[derive(Debug)]
pub struct DoctorUnhealthy;
impl fmt::Display for DoctorUnhealthy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("doctor unhealthy")
}
}
impl std::error::Error for DoctorUnhealthy {}
#[derive(Debug)]
pub struct RefusalSignal;
impl fmt::Display for RefusalSignal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("refusal")
}
}
impl std::error::Error for RefusalSignal {}
#[derive(Debug)]
pub struct NoHitSignal;
impl fmt::Display for NoHitSignal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("no hit")
}
}
impl std::error::Error for NoHitSignal {}

173
crates/kb-app/src/lib.rs Normal file
View File

@@ -0,0 +1,173 @@
//! `kb-app` — facade that downstream `kb-cli` / `kb-tui` / `kb-desktop`
//! depend on (§7, §8).
//!
//! P0 implementations stub out — the signatures are frozen so that later
//! phases swap in real bodies without breaking call sites.
use std::path::PathBuf;
use anyhow::bail;
use serde::{Deserialize, Serialize};
use kb_core::{
Answer, CanonicalDocument, Chunk, ChunkId, DocFilter, DocSummary, DocumentId,
IngestReport, SearchHit, SearchMode, SearchQuery, SourceScope,
};
pub mod doctor_signal;
pub mod logging;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AskOpts {
pub k: usize,
pub explain: bool,
pub mode: SearchMode,
pub temperature: Option<f32>,
pub seed: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DoctorReport {
/// Wire schema version label (`"doctor.v1"`).
pub schema_version: String,
pub ok: bool,
pub checks: Vec<DoctorCheck>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DoctorCheck {
pub name: String,
pub ok: bool,
pub detail: String,
pub hint: Option<String>,
}
/// Create XDG dirs and write a starter `config.toml`. Idempotent unless
/// `force=true` (which overwrites an existing config).
pub fn init_workspace(force: bool) -> anyhow::Result<()> {
let cfg_path = kb_config::Config::xdg_config_path();
let data_dir = kb_config::Config::xdg_data_dir();
let cache_dir = kb_config::Config::xdg_cache_dir();
let state_dir = kb_config::Config::xdg_state_dir();
for d in [
cfg_path.parent().map(PathBuf::from).unwrap_or_default(),
data_dir.clone(),
cache_dir,
state_dir.clone(),
state_dir.join("logs"),
] {
if !d.as_os_str().is_empty() {
std::fs::create_dir_all(&d)?;
}
}
let workspace_root = expand_tilde(&kb_config::Config::defaults().workspace.root);
std::fs::create_dir_all(&workspace_root)?;
if !cfg_path.exists() || force {
let cfg = kb_config::Config::defaults();
let toml_text = toml::to_string_pretty(&cfg)?;
std::fs::write(&cfg_path, toml_text)?;
}
Ok(())
}
fn expand_tilde(s: &str) -> PathBuf {
if let Some(rest) = s.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
if s == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(s)
}
pub fn ingest(_scope: SourceScope, _summary_only: bool) -> anyhow::Result<IngestReport> {
bail!("not yet wired (P1-2)")
}
pub fn list_docs(_filter: DocFilter) -> anyhow::Result<Vec<DocSummary>> {
bail!("not yet wired (P1-5)")
}
pub fn inspect_doc(_id: &DocumentId) -> anyhow::Result<CanonicalDocument> {
bail!("not yet wired (P1-5)")
}
pub fn inspect_chunk(_id: &ChunkId) -> anyhow::Result<Chunk> {
bail!("not yet wired (P1-5)")
}
pub fn search(_query: SearchQuery) -> anyhow::Result<Vec<SearchHit>> {
bail!("not yet wired (P3-1/P4-1)")
}
pub fn ask(_query: &str, _opts: AskOpts) -> anyhow::Result<Answer> {
bail!("not yet wired (P5-1)")
}
/// Run the doctor checks. P0 emits `config_loaded` + `data_dir_writable`
/// (downstream checks land in later phases).
pub fn doctor() -> anyhow::Result<DoctorReport> {
tracing::debug!("doctor() invoked");
let mut checks = Vec::new();
// config_loaded — defaults always load; from-file is best-effort.
let cfg_path = kb_config::Config::xdg_config_path();
let (config_ok, config_detail) = if cfg_path.exists() {
match kb_config::Config::from_file(&cfg_path) {
Ok(_) => (true, cfg_path.display().to_string()),
Err(e) => (false, format!("{} ({e})", cfg_path.display())),
}
} else {
// Defaults are always loadable; report the path that would be read.
(true, format!("{} (defaults)", cfg_path.display()))
};
checks.push(DoctorCheck {
name: "config_loaded".to_string(),
ok: config_ok,
detail: config_detail,
hint: if config_ok {
None
} else {
Some("run `kb init` to seed config".to_string())
},
});
// data_dir_writable — try to create the dir and write a probe file.
let data_dir = kb_config::Config::xdg_data_dir();
let writable = (|| -> anyhow::Result<()> {
std::fs::create_dir_all(&data_dir)?;
let probe = data_dir.join(".kb-doctor-probe");
std::fs::write(&probe, b"ok")?;
std::fs::remove_file(&probe).ok();
Ok(())
})();
let (data_ok, data_detail, data_hint) = match writable {
Ok(()) => (true, data_dir.display().to_string(), None),
Err(e) => (
false,
format!("{} ({e})", data_dir.display()),
Some("ensure XDG_DATA_HOME is writable".to_string()),
),
};
checks.push(DoctorCheck {
name: "data_dir_writable".to_string(),
ok: data_ok,
detail: data_detail,
hint: data_hint,
});
let ok = checks.iter().all(|c| c.ok);
Ok(DoctorReport {
schema_version: "doctor.v1".to_string(),
ok,
checks,
})
}

View File

@@ -0,0 +1,42 @@
//! Tracing initialization helper for `kb-cli`.
//!
//! Daily-rolling file appender at `~/.local/state/kb/logs/` per task spec.
//! Returns a `WorkerGuard` that the caller must keep alive until program
//! exit (so buffered log lines flush).
use anyhow::Result;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LogLevel {
Default,
Verbose,
Debug,
}
/// Initialize tracing. Returns a guard to keep alive until exit. Idempotent
/// — a second call is a no-op.
pub fn init(level: LogLevel) -> Result<Option<WorkerGuard>> {
let log_dir = kb_config::Config::xdg_state_dir().join("logs");
std::fs::create_dir_all(&log_dir)?;
let file_appender = tracing_appender::rolling::daily(&log_dir, "kb.log");
let (nb, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = match level {
LogLevel::Default => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
LogLevel::Verbose => EnvFilter::new("info"),
LogLevel::Debug => EnvFilter::new("debug"),
};
let registry = tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().with_writer(nb).with_ansi(false));
// `try_init` rather than `init` so a second call (e.g. in tests) is a
// no-op.
let _ = registry.try_init();
Ok(Some(guard))
}