use std::path::{Path, PathBuf};
use chrono::{DateTime, FixedOffset};
use crate::cli::{IndexArgs, IndexCommand, IndexQueryArgs, IndexRebuildArgs, IndexShowArgs};
use crate::cmd::fm::parse_layer;
use crate::cmd::log::{into_cli, open_store, parse_flexible_timestamp};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
use dbmd_core::{Index, IndexLevel, IndexRecord, Layer, Query, Store};
pub fn run(ctx: &Context, args: &IndexArgs) -> CliResult {
match &args.command {
IndexCommand::Rebuild(a) => run_rebuild(ctx, a),
IndexCommand::Show(a) => run_show(ctx, a),
IndexCommand::Query(a) => run_query(ctx, a),
}
}
pub fn run_rebuild(ctx: &Context, args: &IndexRebuildArgs) -> CliResult {
let store = open_store(&args.dir)?;
if args.layer.is_some() && args.folder.is_some() {
return Err(CliError::new(
ExitCode::Runtime,
"BAD_SCOPE",
"pass at most one of --layer / --folder",
));
}
let scope = if let Some(folder) = &args.folder {
RebuildScope::Folder(normalize_rel(Path::new(folder)))
} else if let Some(layer) = &args.layer {
RebuildScope::Layer(parse_layer(layer)?)
} else {
RebuildScope::Full
};
if args.dry_run {
let preview = render_dry_run(&store, &scope)?;
print!("{preview}");
return Ok(());
}
match &scope {
RebuildScope::Full => Index::rebuild_all(&store)?,
RebuildScope::Layer(layer) => Index::write_level(&store, &IndexLevel::Layer(*layer))?,
RebuildScope::Folder(folder) => {
Index::write_level(&store, &IndexLevel::TypeFolder(folder.clone()))?
}
}
if ctx.json {
let obj = serde_json::json!({ "rebuilt": true, "scope": scope.describe() });
println!("{obj}");
} else {
println!("rebuilt {}", scope.describe());
}
Ok(())
}
pub fn run_show(_ctx: &Context, args: &IndexShowArgs) -> CliResult {
let store = open_store(&args.dir)?;
let index_md = match &args.path {
Some(p) => store
.root
.join(normalize_rel(Path::new(p)))
.join("index.md"),
None => store.root.join("index.md"),
};
match std::fs::read_to_string(&index_md) {
Ok(contents) => {
print!("{contents}");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let where_ = args.path.as_deref().unwrap_or(".");
Err(CliError::new(
ExitCode::Runtime,
"INDEX_MISSING",
format!("no index.md at {where_}; run `dbmd index rebuild` to create"),
))
}
Err(e) => Err(e.into()),
}
}
pub fn run_query(ctx: &Context, args: &IndexQueryArgs) -> CliResult {
let store = open_store(&args.dir)?;
let mut query = Query::new();
if let Some(t) = &args.r#type {
query = query.with_type(t);
}
if let Some(layer) = &args.r#in {
query = query.with_layer(parse_layer(layer)?);
}
for clause in &args.r#where {
let (k, v) = split_where(clause)?;
query = query.with_where(k, v);
}
let mut records = into_cli(query.execute(&store))?;
let win = TimeWindow::from_args(args)?;
records.retain(|r| win.accepts(r));
if let Some(limit) = args.limit {
records.truncate(limit);
}
emit_records(ctx, &records);
Ok(())
}
enum RebuildScope {
Full,
Layer(Layer),
Folder(PathBuf),
}
impl RebuildScope {
fn describe(&self) -> String {
match self {
RebuildScope::Full => "full hierarchy".to_string(),
RebuildScope::Layer(l) => format!("layer {}", l.dir_name()),
RebuildScope::Folder(p) => format!("folder {}", path_str(p)),
}
}
}
fn render_dry_run(store: &Store, scope: &RebuildScope) -> Result<String, CliError> {
let mut out = String::new();
match scope {
RebuildScope::Folder(folder) => {
out.push_str(&Index::render_dry_run(
store,
&IndexLevel::TypeFolder(folder.clone()),
)?);
}
RebuildScope::Layer(layer) => {
for tf in type_folders_in_layer(store, *layer) {
out.push_str(&Index::render_dry_run(store, &IndexLevel::TypeFolder(tf))?);
}
out.push_str(&Index::render_dry_run(store, &IndexLevel::Layer(*layer))?);
}
RebuildScope::Full => {
for layer in Layer::all() {
for tf in type_folders_in_layer(store, layer) {
out.push_str(&Index::render_dry_run(store, &IndexLevel::TypeFolder(tf))?);
}
out.push_str(&Index::render_dry_run(store, &IndexLevel::Layer(layer))?);
}
out.push_str(&Index::render_dry_run(store, &IndexLevel::Root)?);
}
}
Ok(out)
}
fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
let layer_dir = store.root.join(layer.dir_name());
let mut out = Vec::new();
let rd = match std::fs::read_dir(&layer_dir) {
Ok(rd) => rd,
Err(_) => return out,
};
for entry in rd.flatten() {
if !entry.path().is_dir() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with('.') || name == "log" {
continue;
}
out.push(PathBuf::from(layer.dir_name()).join(name));
}
out.sort();
out
}
pub(crate) fn emit_records(ctx: &Context, records: &[IndexRecord]) {
if ctx.json {
let arr: Vec<serde_json::Value> = records
.iter()
.map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null))
.collect();
println!("{}", serde_json::Value::Array(arr));
} else {
for r in records {
println!("{}", path_str(&r.path));
}
}
}
struct TimeWindow {
updated_after: Option<DateTime<FixedOffset>>,
updated_before: Option<DateTime<FixedOffset>>,
created_after: Option<DateTime<FixedOffset>>,
created_before: Option<DateTime<FixedOffset>>,
}
impl TimeWindow {
fn from_args(args: &IndexQueryArgs) -> Result<TimeWindow, CliError> {
Ok(TimeWindow {
updated_after: opt_ts(&args.updated_after)?,
updated_before: opt_ts(&args.updated_before)?,
created_after: opt_ts(&args.created_after)?,
created_before: opt_ts(&args.created_before)?,
})
}
fn accepts(&self, record: &IndexRecord) -> bool {
if let Some(bound) = self.updated_after {
match record.updated {
Some(u) if u >= bound => {}
_ => return false,
}
}
if let Some(bound) = self.updated_before {
match record.updated {
Some(u) if u <= bound => {}
_ => return false,
}
}
if let Some(bound) = self.created_after {
match record.created {
Some(c) if c >= bound => {}
_ => return false,
}
}
if let Some(bound) = self.created_before {
match record.created {
Some(c) if c <= bound => {}
_ => return false,
}
}
true
}
}
fn opt_ts(raw: &Option<String>) -> Result<Option<DateTime<FixedOffset>>, CliError> {
match raw {
Some(s) => Ok(Some(parse_flexible_timestamp(s)?)),
None => Ok(None),
}
}
fn split_where(clause: &str) -> Result<(&str, &str), CliError> {
match clause.split_once('=') {
Some((k, v)) if !k.is_empty() => Ok((k, v)),
_ => Err(CliError::new(
ExitCode::Runtime,
"BAD_WHERE",
format!("--where expects `key=value`, got {clause:?}"),
)),
}
}
fn normalize_rel(p: &Path) -> PathBuf {
let s = path_str(p);
PathBuf::from(s.strip_prefix("./").unwrap_or(&s))
}
fn path_str(p: &Path) -> String {
p.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}