use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use kbolt_types::{
DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus, KboltError, ModelInfo,
ModelStatus,
};
use rusqlite::{Connection, OpenFlags};
use tantivy::Index;
use usearch::{IndexOptions, MetricKind, ScalarKind};
use crate::config;
use crate::config::RoleBindingsConfig;
use crate::models;
use crate::models::EmbeddingInputKind;
const SQLITE_FILE_NAME: &str = "meta.sqlite";
const SPACES_DIR_NAME: &str = "spaces";
const TANTIVY_DIR_NAME: &str = "tantivy";
const USEARCH_FILE_NAME: &str = "vectors.usearch";
const WRITE_CHECK_FILE_NAME: &str = ".kbolt-doctor-write-check";
const SETUP_FIX: &str =
"Create an index.toml with provider profiles and role bindings, then rerun `kbolt doctor`.";
pub fn run(config_path: Option<&Path>) -> DoctorReport {
let mut checks = Vec::new();
let mut config_file = None;
let mut config_dir = None;
let mut cache_dir = None;
let resolved_config_file = match timed_result_check(
"config.path_resolves",
"config",
Some("Use the platform default config location or pass a valid kbolt config directory."),
|| config::resolve_config_file_path(config_path),
) {
(check, Ok(path)) => {
checks.push(check);
config_dir = path.parent().map(Path::to_path_buf);
config_file = Some(path.clone());
path
}
(check, Err(_)) => {
checks.push(check);
return finalize_report(
DoctorSetupStatus::ConfigInvalid,
config_file,
config_dir,
cache_dir,
checks,
);
}
};
if !resolved_config_file.exists() {
checks.push(timed_static_check(
"config.file_exists",
"config",
DoctorCheckStatus::Fail,
format!(
"kbolt is not set up yet: config file does not exist at {}",
resolved_config_file.display()
),
Some(SETUP_FIX.to_string()),
));
return finalize_report(
DoctorSetupStatus::ConfigMissing,
config_file,
config_dir,
cache_dir,
checks,
);
}
let config = match timed_result_check(
"config.file_parses",
"config",
Some("Fix the TOML config file and rerun `kbolt doctor`."),
|| config::load_existing(Some(&resolved_config_file)),
) {
(check, Ok(config)) => {
cache_dir = Some(config.cache_dir.clone());
checks.push(check);
config
}
(check, Err(_)) => {
checks.push(check);
return finalize_report(
DoctorSetupStatus::ConfigInvalid,
config_file,
config_dir,
cache_dir,
checks,
);
}
};
if is_not_configured(&config.roles) {
checks.push(timed_static_check(
"config.roles_bound",
"config",
DoctorCheckStatus::Fail,
"kbolt is not set up yet: no inference roles are configured".to_string(),
Some(SETUP_FIX.to_string()),
));
return finalize_report(
DoctorSetupStatus::NotConfigured,
config_file,
config_dir,
cache_dir,
checks,
);
}
checks.push(timed_static_check(
"config.roles_bound",
"config",
DoctorCheckStatus::Pass,
"at least one inference role is configured".to_string(),
None,
));
if let Some(path) = config_dir.as_deref() {
checks.push(check_directory_writable(
"config.dir_writable",
"config",
path,
));
}
checks.push(check_cache_dir(&config.cache_dir));
checks.push(check_sqlite_readable(&config.cache_dir));
checks.push(check_search_indexes_readable(&config.cache_dir));
let model_status = match models::status(&config) {
Ok(status) => status,
Err(err) => {
checks.push(timed_static_check(
"models.status",
"models",
DoctorCheckStatus::Fail,
format!("failed to resolve model status: {err}"),
Some("Fix provider profiles and role bindings in index.toml.".to_string()),
));
return finalize_report(
DoctorSetupStatus::Configured,
config_file,
config_dir,
cache_dir,
checks,
);
}
};
checks.extend(role_binding_checks(&config.roles, &model_status));
checks.extend(role_readiness_checks(&model_status));
let built_models = match timed_result_check(
"models.build_clients",
"models",
Some("Fix provider profile settings that are incompatible with their bound roles."),
|| models::build_inference_clients(&config),
) {
(check, Ok(models)) => {
checks.push(check);
models
}
(check, Err(_)) => {
checks.push(check);
return finalize_report(
DoctorSetupStatus::Configured,
config_file,
config_dir,
cache_dir,
checks,
);
}
};
if model_status.embedder.ready {
if let Some(embedder) = built_models.embedder.as_ref() {
checks.push(timed_unit_check(
"roles.embedder.embedding_smoke",
"roles.embedder",
Some("Check the embedder model, endpoint path, and server logs."),
|| {
let input = vec!["kbolt doctor embedding smoke".to_string()];
let vectors = embedder.embed_batch(EmbeddingInputKind::Query, &input)?;
if vectors.len() != 1 || vectors[0].is_empty() {
return Err(KboltError::Inference(
"embedding smoke returned an empty response".to_string(),
)
.into());
}
Ok(())
},
));
}
if let Some(sizer) = built_models.embedding_document_sizer.as_ref() {
checks.push(timed_unit_check(
"roles.embedder.tokenize_smoke",
"roles.embedder",
Some("Verify the llama.cpp embedding server exposes `/tokenize`."),
|| {
let count = sizer.count_document_tokens("kbolt doctor tokenize smoke")?;
if count == 0 {
return Err(KboltError::Inference(
"tokenize smoke returned zero tokens".to_string(),
)
.into());
}
Ok(())
},
));
}
}
if model_status.reranker.ready {
if let Some(reranker) = built_models.reranker.as_ref() {
checks.push(timed_unit_check(
"roles.reranker.rerank_smoke",
"roles.reranker",
Some("Check the reranker model, endpoint path, and server logs."),
|| {
let scores = reranker.rerank(
"kbolt doctor rerank smoke",
&["kbolt doctor rerank document".to_string()],
)?;
if scores.len() != 1 || !scores[0].is_finite() {
return Err(KboltError::Inference(
"rerank smoke returned an invalid response".to_string(),
)
.into());
}
Ok(())
},
));
}
}
if model_status.expander.ready {
if let Some(expander) = built_models.expander.as_ref() {
checks.push(timed_unit_check(
"roles.expander.chat_smoke",
"roles.expander",
Some("Check the expander model, endpoint path, and server logs."),
|| {
let variants = expander.expand("kbolt doctor smoke", 2)?;
if variants.is_empty() {
return Err(KboltError::Inference(
"expander smoke returned no variants".to_string(),
)
.into());
}
Ok(())
},
));
}
}
finalize_report(
DoctorSetupStatus::Configured,
config_file,
config_dir,
cache_dir,
checks,
)
}
fn role_binding_checks(roles: &RoleBindingsConfig, model_status: &ModelStatus) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(role_binding_check(
"roles.embedder.bound",
"roles.embedder",
roles.embedder.is_some(),
&model_status.embedder,
"configure `[roles.embedder]` to enable semantic search and embeddings",
));
checks.push(role_binding_check(
"roles.reranker.bound",
"roles.reranker",
roles.reranker.is_some(),
&model_status.reranker,
"configure `[roles.reranker]` to enable reranking",
));
checks.push(role_binding_check(
"roles.expander.bound",
"roles.expander",
roles.expander.is_some(),
&model_status.expander,
"configure `[roles.expander]` to enable deep query expansion",
));
checks
}
fn role_binding_check(
id: &str,
scope: &str,
configured: bool,
info: &ModelInfo,
fix: &str,
) -> DoctorCheck {
if configured {
timed_static_check(
id,
scope,
DoctorCheckStatus::Pass,
format!(
"bound to profile={} kind={} operation={} model={} endpoint={}",
info.profile.as_deref().unwrap_or("unknown"),
info.kind.as_deref().unwrap_or("unknown"),
info.operation.as_deref().unwrap_or("unknown"),
info.model.as_deref().unwrap_or("unknown"),
info.endpoint.as_deref().unwrap_or("unknown"),
),
None,
)
} else {
timed_static_check(
id,
scope,
DoctorCheckStatus::Warn,
"role is not configured".to_string(),
Some(fix.to_string()),
)
}
}
fn role_readiness_checks(model_status: &ModelStatus) -> Vec<DoctorCheck> {
vec![
role_readiness_check(
"roles.embedder.reachable",
"roles.embedder",
&model_status.embedder,
),
role_readiness_check(
"roles.reranker.reachable",
"roles.reranker",
&model_status.reranker,
),
role_readiness_check(
"roles.expander.reachable",
"roles.expander",
&model_status.expander,
),
]
}
fn role_readiness_check(id: &str, scope: &str, info: &ModelInfo) -> DoctorCheck {
if !info.configured {
return timed_static_check(
id,
scope,
DoctorCheckStatus::Warn,
"skipped because role is not configured".to_string(),
None,
);
}
if info.ready {
return timed_static_check(
id,
scope,
DoctorCheckStatus::Pass,
"provider endpoint is reachable".to_string(),
None,
);
}
timed_static_check(
id,
scope,
DoctorCheckStatus::Fail,
info.issue
.clone()
.unwrap_or_else(|| "provider endpoint is not ready".to_string()),
Some(
"Start the provider server or fix the provider endpoint/auth configuration."
.to_string(),
),
)
}
fn check_cache_dir(cache_dir: &Path) -> DoctorCheck {
if !cache_dir.exists() {
return timed_static_check(
"cache.dir_exists",
"storage",
DoctorCheckStatus::Warn,
format!(
"cache directory does not exist yet: {}",
cache_dir.display()
),
Some("This is expected before the first successful indexing run.".to_string()),
);
}
check_directory_writable("cache.dir_writable", "storage", cache_dir)
}
fn check_sqlite_readable(cache_dir: &Path) -> DoctorCheck {
let db_path = cache_dir.join(SQLITE_FILE_NAME);
if !db_path.exists() {
return timed_static_check(
"storage.sqlite_readable",
"storage",
DoctorCheckStatus::Warn,
format!("index database does not exist yet: {}", db_path.display()),
Some("Run `kbolt update` after adding a collection to build the index.".to_string()),
);
}
timed_unit_check(
"storage.sqlite_readable",
"storage",
Some("Check filesystem permissions or back up and rebuild the index if the DB is corrupt."),
|| {
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let _: i64 = conn.query_row("SELECT 1", [], |row| row.get(0))?;
Ok(())
},
)
}
fn check_search_indexes_readable(cache_dir: &Path) -> DoctorCheck {
let spaces_dir = cache_dir.join(SPACES_DIR_NAME);
if !spaces_dir.exists() {
return timed_static_check(
"storage.search_indexes_readable",
"storage",
DoctorCheckStatus::Warn,
format!(
"search index directory does not exist yet: {}",
spaces_dir.display()
),
Some(
"Run `kbolt update` after adding a collection to build search indexes.".to_string(),
),
);
}
let has_space_dirs = match fs::read_dir(&spaces_dir) {
Ok(entries) => entries
.filter_map(std::result::Result::ok)
.any(|entry| entry.path().is_dir()),
Err(err) => {
return timed_static_check(
"storage.search_indexes_readable",
"storage",
DoctorCheckStatus::Fail,
err.to_string(),
Some("Check filesystem permissions on the cache spaces directory.".to_string()),
)
}
};
if !has_space_dirs {
return timed_static_check(
"storage.search_indexes_readable",
"storage",
DoctorCheckStatus::Warn,
format!("search index directory is empty: {}", spaces_dir.display()),
Some(
"Run `kbolt update` after adding a collection to build search indexes.".to_string(),
),
);
}
timed_unit_check(
"storage.search_indexes_readable",
"storage",
Some("Back up the cache directory and rebuild the affected space indexes if they are corrupt."),
|| {
for entry in fs::read_dir(&spaces_dir)? {
let space_dir = entry?.path();
if !space_dir.is_dir() {
continue;
}
check_tantivy_space_index(&space_dir.join(TANTIVY_DIR_NAME))?;
check_usearch_space_index(&space_dir.join(USEARCH_FILE_NAME))?;
}
Ok(())
},
)
}
fn check_tantivy_space_index(index_dir: &Path) -> crate::Result<()> {
if !index_dir.exists() {
return Ok(());
}
Index::open_in_dir(index_dir)?;
Ok(())
}
fn check_usearch_space_index(index_path: &Path) -> crate::Result<()> {
let file_size = fs::metadata(index_path).map(|meta| meta.len()).unwrap_or(0);
if file_size == 0 {
return Ok(());
}
let path = index_path
.to_str()
.ok_or_else(|| KboltError::Internal("invalid usearch path encoding".to_string()))?;
let index = usearch::Index::new(&IndexOptions {
dimensions: 256,
metric: MetricKind::Cos,
quantization: ScalarKind::F32,
connectivity: 16,
expansion_add: 200,
expansion_search: 100,
..IndexOptions::default()
})
.map_err(|err| KboltError::Internal(format!("usearch init failed: {err}")))?;
index
.load(path)
.map_err(|err| KboltError::Internal(format!("usearch load failed: {err}")))?;
if index.size() == 0 {
return Err(KboltError::Internal(format!(
"usearch index file is non-empty but loaded zero vectors: {}",
index_path.display()
))
.into());
}
Ok(())
}
fn check_directory_writable(id: &str, scope: &str, dir: &Path) -> DoctorCheck {
if !dir.exists() {
return timed_static_check(
id,
scope,
DoctorCheckStatus::Warn,
format!("directory does not exist yet: {}", dir.display()),
Some(
"Create the directory or run the setup/indexing command that owns it.".to_string(),
),
);
}
timed_unit_check(
id,
scope,
Some("Fix directory permissions so kbolt can write state files."),
|| {
let probe = writable_probe_path(dir);
fs::write(&probe, b"doctor")?;
fs::remove_file(&probe)?;
Ok(())
},
)
}
fn writable_probe_path(dir: &Path) -> PathBuf {
dir.join(format!(
"{WRITE_CHECK_FILE_NAME}-{}.tmp",
std::process::id()
))
}
fn timed_unit_check<F>(id: &str, scope: &str, fix: Option<&str>, check: F) -> DoctorCheck
where
F: FnOnce() -> crate::Result<()>,
{
let started = Instant::now();
let outcome = check();
let elapsed_ms = elapsed_ms(started);
match outcome {
Ok(()) => DoctorCheck {
id: id.to_string(),
scope: scope.to_string(),
status: DoctorCheckStatus::Pass,
elapsed_ms,
message: "ok".to_string(),
fix: None,
},
Err(err) => DoctorCheck {
id: id.to_string(),
scope: scope.to_string(),
status: DoctorCheckStatus::Fail,
elapsed_ms,
message: err.to_string(),
fix: fix.map(ToString::to_string),
},
}
}
fn timed_result_check<T, F>(
id: &str,
scope: &str,
fix: Option<&str>,
check: F,
) -> (DoctorCheck, crate::Result<T>)
where
F: FnOnce() -> crate::Result<T>,
{
let started = Instant::now();
let outcome = check();
let elapsed_ms = elapsed_ms(started);
let report_check = match &outcome {
Ok(_) => DoctorCheck {
id: id.to_string(),
scope: scope.to_string(),
status: DoctorCheckStatus::Pass,
elapsed_ms,
message: "ok".to_string(),
fix: None,
},
Err(err) => DoctorCheck {
id: id.to_string(),
scope: scope.to_string(),
status: DoctorCheckStatus::Fail,
elapsed_ms,
message: err.to_string(),
fix: fix.map(ToString::to_string),
},
};
(report_check, outcome)
}
fn timed_static_check(
id: &str,
scope: &str,
status: DoctorCheckStatus,
message: String,
fix: Option<String>,
) -> DoctorCheck {
let started = Instant::now();
DoctorCheck {
id: id.to_string(),
scope: scope.to_string(),
status,
elapsed_ms: elapsed_ms(started),
message,
fix,
}
}
fn elapsed_ms(started: Instant) -> u64 {
started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)
}
fn is_not_configured(roles: &RoleBindingsConfig) -> bool {
roles.embedder.is_none() && roles.reranker.is_none() && roles.expander.is_none()
}
fn finalize_report(
setup_status: DoctorSetupStatus,
config_file: Option<PathBuf>,
config_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
checks: Vec<DoctorCheck>,
) -> DoctorReport {
let ready = setup_status == DoctorSetupStatus::Configured
&& checks
.iter()
.all(|check| check.status != DoctorCheckStatus::Fail);
DoctorReport {
setup_status,
config_file,
config_dir,
cache_dir,
ready,
checks,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Mutex, OnceLock};
use std::thread;
use tempfile::tempdir;
use super::*;
use crate::config::{
ChunkingConfig, Config, EmbedderRoleConfig, ProviderOperation, ProviderProfileConfig,
RankingConfig, ReapingConfig,
};
struct EnvRestore {
home: Option<OsString>,
config_home: Option<OsString>,
cache_home: Option<OsString>,
}
impl EnvRestore {
fn capture() -> Self {
Self {
home: std::env::var_os("HOME"),
config_home: std::env::var_os("XDG_CONFIG_HOME"),
cache_home: std::env::var_os("XDG_CACHE_HOME"),
}
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
match &self.home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.config_home {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match &self.cache_home {
Some(value) => std::env::set_var("XDG_CACHE_HOME", value),
None => std::env::remove_var("XDG_CACHE_HOME"),
}
}
}
fn with_isolated_xdg_dirs(run: impl FnOnce()) {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let _restore = EnvRestore::capture();
let tmp = tempdir().expect("create env tempdir");
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("config-home"));
std::env::set_var("XDG_CACHE_HOME", tmp.path().join("cache-home"));
run();
}
#[test]
fn run_reports_missing_config_without_creating_files() {
with_isolated_xdg_dirs(|| {
let tmp = tempdir().expect("create tempdir");
let config_dir = tmp.path().join("config");
let report = run(Some(&config_dir));
assert_eq!(report.setup_status, DoctorSetupStatus::ConfigMissing);
assert!(!report.ready);
assert!(!config_dir.exists());
assert!(report
.checks
.iter()
.any(|check| check.id == "config.file_exists"
&& check.status == DoctorCheckStatus::Fail));
});
}
#[test]
fn run_reports_invalid_config_file() {
with_isolated_xdg_dirs(|| {
let tmp = tempdir().expect("create tempdir");
let config_dir = tmp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(config_dir.join("index.toml"), "providers = [").expect("write config");
let report = run(Some(&config_dir));
assert_eq!(report.setup_status, DoctorSetupStatus::ConfigInvalid);
assert!(!report.ready);
assert!(report
.checks
.iter()
.any(|check| check.id == "config.file_parses"
&& check.status == DoctorCheckStatus::Fail));
});
}
#[test]
fn run_reports_not_configured_when_roles_are_empty() {
with_isolated_xdg_dirs(|| {
let tmp = tempdir().expect("create tempdir");
let config_dir = tmp.path().join("config");
let cache_dir = tmp.path().join("cache");
let config = Config {
config_dir: config_dir.clone(),
cache_dir,
default_space: None,
providers: HashMap::new(),
roles: RoleBindingsConfig::default(),
reaping: ReapingConfig { days: 7 },
chunking: ChunkingConfig::default(),
ranking: RankingConfig::default(),
};
config::save(&config).expect("save config");
let report = run(Some(&config_dir));
assert_eq!(report.setup_status, DoctorSetupStatus::NotConfigured);
assert!(!report.ready);
assert!(report
.checks
.iter()
.any(|check| check.id == "config.roles_bound"
&& check.status == DoctorCheckStatus::Fail));
});
}
#[test]
fn run_smoke_checks_llama_embedder_and_tokenizer() {
with_isolated_xdg_dirs(|| {
let endpoint = serve_llama_embedder_and_tokenize();
let tmp = tempdir().expect("create tempdir");
let config_dir = tmp.path().join("config");
let cache_dir = tmp.path().join("cache");
let config = Config {
config_dir: config_dir.clone(),
cache_dir,
default_space: None,
providers: HashMap::from([(
"local_embed".to_string(),
ProviderProfileConfig::LlamaCppServer {
operation: ProviderOperation::Embedding,
base_url: endpoint,
model: "embeddinggemma".to_string(),
parallel_requests: None,
timeout_ms: 5_000,
max_retries: 0,
},
)]),
roles: RoleBindingsConfig {
embedder: Some(EmbedderRoleConfig {
provider: "local_embed".to_string(),
batch_size: 16,
}),
reranker: None,
expander: None,
},
reaping: ReapingConfig { days: 7 },
chunking: ChunkingConfig::default(),
ranking: RankingConfig::default(),
};
config::save(&config).expect("save config");
let report = run(Some(&config_dir));
assert_eq!(report.setup_status, DoctorSetupStatus::Configured);
assert!(report.ready, "unexpected report: {report:#?}");
assert!(report
.checks
.iter()
.any(|check| check.id == "roles.embedder.embedding_smoke"
&& check.status == DoctorCheckStatus::Pass));
assert!(report
.checks
.iter()
.any(|check| check.id == "roles.embedder.tokenize_smoke"
&& check.status == DoctorCheckStatus::Pass));
});
}
#[test]
fn run_reports_corrupt_search_indexes_as_not_ready() {
with_isolated_xdg_dirs(|| {
let endpoint = serve_llama_embedder_and_tokenize();
let tmp = tempdir().expect("create tempdir");
let config_dir = tmp.path().join("config");
let cache_dir = tmp.path().join("cache");
let config = Config {
config_dir: config_dir.clone(),
cache_dir: cache_dir.clone(),
default_space: None,
providers: HashMap::from([(
"local_embed".to_string(),
ProviderProfileConfig::LlamaCppServer {
operation: ProviderOperation::Embedding,
base_url: endpoint,
model: "embeddinggemma".to_string(),
parallel_requests: None,
timeout_ms: 5_000,
max_retries: 0,
},
)]),
roles: RoleBindingsConfig {
embedder: Some(EmbedderRoleConfig {
provider: "local_embed".to_string(),
batch_size: 16,
}),
reranker: None,
expander: None,
},
reaping: ReapingConfig { days: 7 },
chunking: ChunkingConfig::default(),
ranking: RankingConfig::default(),
};
config::save(&config).expect("save config");
let effective_cache_dir = config::load_existing(Some(&config_dir))
.expect("load saved config")
.cache_dir;
let bad_space_dir = effective_cache_dir.join("spaces").join("default");
fs::create_dir_all(&bad_space_dir).expect("create bad space dir");
fs::write(bad_space_dir.join("tantivy"), "not an index")
.expect("write bad tantivy artifact");
let report = run(Some(&config_dir));
assert_eq!(report.setup_status, DoctorSetupStatus::Configured);
assert!(!report.ready, "unexpected report: {report:#?}");
assert!(report.checks.iter().any(|check| {
check.id == "storage.search_indexes_readable"
&& check.status == DoctorCheckStatus::Fail
}));
});
}
fn serve_llama_embedder_and_tokenize() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("read server addr");
thread::spawn(move || {
for _ in 0..3 {
let (mut stream, _) = listener.accept().expect("accept request");
let request_path = read_request_path(&mut stream);
let body = if request_path == "/" {
r#"{"status":"ok"}"#
} else if request_path == "/v1/embeddings" {
r#"{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}]}"#
} else if request_path == "/tokenize" {
r#"{"tokens":[1,2,3]}"#
} else {
r#"{"error":"unexpected path"}"#
};
let status_line = if body.contains("unexpected path") {
"HTTP/1.1 404 Not Found"
} else {
"HTTP/1.1 200 OK"
};
let response = format!(
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("write response");
}
});
format!("http://{addr}")
}
fn read_request_path(stream: &mut std::net::TcpStream) -> String {
let mut raw = [0_u8; 8192];
let mut received = Vec::new();
let mut expected_len = None;
while expected_len.map_or(true, |len| received.len() < len) {
let read_len = stream.read(&mut raw).expect("read request");
if read_len == 0 {
break;
}
received.extend_from_slice(&raw[..read_len]);
if expected_len.is_none() {
if let Some(header_end) = received
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|offset| offset + 4)
{
let headers =
String::from_utf8_lossy(&received[..header_end]).to_ascii_lowercase();
let mut content_length = 0usize;
for line in headers.lines() {
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse::<usize>().unwrap_or(0);
break;
}
}
expected_len = Some(header_end.saturating_add(content_length));
}
}
}
let request_line_end = received
.windows(2)
.position(|window| window == b"\r\n")
.unwrap_or(received.len());
let request_line = String::from_utf8_lossy(&received[..request_line_end]);
request_line
.split_whitespace()
.nth(1)
.unwrap_or("")
.to_string()
}
}