hololake-system-architecture/product-source/hololake-clean-desktop/src-tauri/src/storage.rs

279 lines
8.7 KiB
Rust

use crate::model::{
Channel, ExternalAiBridge, KnowledgeDocument, KnowledgeSummary, SourceKind, TimelineEvent,
};
use chrono::{DateTime, SecondsFormat, Utc};
use sha2::{Digest, Sha256};
use std::{
fs,
path::{Component, Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use tauri::{AppHandle, Manager};
pub fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
pub fn now_unix_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
pub fn id(prefix: &str) -> String {
format!("{}-{}", prefix, UuidPart::new())
}
struct UuidPart(String);
impl UuidPart {
fn new() -> Self {
Self(uuid::Uuid::new_v4().simple().to_string()[..12].to_uppercase())
}
}
impl std::fmt::Display for UuidPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
pub fn sha(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
pub fn root(app: &AppHandle) -> Result<PathBuf, String> {
if let Some(value) = std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT") {
let path = PathBuf::from(value);
if !path.is_absolute() || !path.starts_with(std::env::temp_dir()) {
return Err("HOLOLAKE_RUNTIME_TEST_ROOT_OUT_OF_SCOPE".into());
}
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
return Ok(path);
}
let p = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("runtime-v1");
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
Ok(p)
}
pub fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
serde_json::from_slice(&fs::read(p).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
}
pub fn write_json<T: serde::Serialize + ?Sized>(p: &Path, v: &T) -> Result<(), String> {
if let Some(x) = p.parent() {
fs::create_dir_all(x).map_err(|e| e.to_string())?
}
let tmp = p.with_extension("tmp");
fs::write(
&tmp,
serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
fs::rename(tmp, p).map_err(|e| e.to_string())
}
pub fn channel(app: &AppHandle) -> Result<Option<Channel>, String> {
let p = root(app)?.join("channel.json");
if p.exists() {
read_json(&p).map(Some)
} else {
Ok(None)
}
}
pub fn save_channel(app: &AppHandle, c: &Channel) -> Result<(), String> {
write_json(&root(app)?.join("channel.json"), c)
}
pub fn event(
kind: SourceKind,
title: impl Into<String>,
content: impl Into<String>,
state: &str,
) -> TimelineEvent {
TimelineEvent {
event_id: id("HL-EVT"),
source_kind: kind,
actor_id: None,
title: title.into(),
content: content.into(),
occurred_at: now(),
state: state.into(),
}
}
pub fn actor_event(
kind: SourceKind,
actor_id: impl Into<String>,
title: impl Into<String>,
content: impl Into<String>,
state: &str,
) -> TimelineEvent {
let mut event = event(kind, title, content, state);
event.actor_id = Some(actor_id.into());
event
}
pub fn append_event(app: &AppHandle, e: &TimelineEvent) -> Result<(), String> {
let p = root(app)?.join("events.jsonl");
let mut s = if p.exists() {
fs::read_to_string(&p).map_err(|e| e.to_string())?
} else {
String::new()
};
s.push_str(&serde_json::to_string(e).map_err(|e| e.to_string())?);
s.push('\n');
fs::write(p, s).map_err(|e| e.to_string())
}
pub fn events(app: &AppHandle) -> Result<Vec<TimelineEvent>, String> {
let p = root(app)?.join("events.jsonl");
if !p.exists() {
return Ok(vec![]);
};
let mut v = fs::read_to_string(p)
.map_err(|e| e.to_string())?
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect::<Vec<_>>();
v.reverse();
Ok(v)
}
pub fn knowledge_root(app: &AppHandle) -> Result<PathBuf, String> {
let p = root(app)?.join("private-repository").join("knowledge");
fs::create_dir_all(p.join("docs")).map_err(|e| e.to_string())?;
ensure_git(&p)?;
Ok(p)
}
pub fn private_repo_root(app: &AppHandle) -> Result<PathBuf, String> {
let p = root(app)?.join("private-repository");
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
ensure_git(&p)?;
Ok(p)
}
pub fn ensure_git(p: &Path) -> Result<(), String> {
if !p.join(".git").exists() {
run_git(p, &["init", "--initial-branch=main"])?;
run_git(p, &["config", "user.name", "HoloLake Local"])?;
run_git(p, &["config", "user.email", "local@hololake.invalid"])?;
}
Ok(())
}
pub fn git_commit(p: &Path, msg: &str) -> Result<Option<String>, String> {
run_git(p, &["add", "."])?;
let status = run_git(p, &["status", "--porcelain"])?;
if status.trim().is_empty() {
return Ok(None);
};
run_git(p, &["commit", "-m", msg])?;
Ok(Some(run_git(p, &["rev-parse", "HEAD"])?.trim().into()))
}
fn run_git(p: &Path, args: &[&str]) -> Result<String, String> {
let o = Command::new("git")
.current_dir(p)
.args(args)
.output()
.map_err(|e| format!("GIT_UNAVAILABLE: {e}"))?;
if !o.status.success() {
return Err(format!(
"GIT_FAILED: {}",
String::from_utf8_lossy(&o.stderr)
));
};
Ok(String::from_utf8_lossy(&o.stdout).into_owned())
}
pub fn safe_rel(value: &str) -> Result<PathBuf, String> {
let p = Path::new(value);
if p.is_absolute()
|| p.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err("PATH_OUT_OF_SCOPE".into());
}
Ok(p.to_path_buf())
}
pub fn list_docs(app: &AppHandle) -> Result<Vec<KnowledgeSummary>, String> {
let root = knowledge_root(app)?.join("docs");
let mut out = vec![];
collect(&root, &root, &mut out)?;
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(out)
}
fn collect(root: &Path, p: &Path, out: &mut Vec<KnowledgeSummary>) -> Result<(), String> {
for e in fs::read_dir(p).map_err(|e| e.to_string())? {
let e = e.map_err(|e| e.to_string())?;
let path = e.path();
if path.is_dir() {
collect(root, &path, out)?
} else if path.extension().and_then(|x| x.to_str()) == Some("md") {
let b = fs::read(&path).map_err(|e| e.to_string())?;
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.into_owned();
let body = String::from_utf8_lossy(&b);
let title = body
.lines()
.find_map(|x| x.strip_prefix("# "))
.unwrap_or_else(|| {
path.file_stem()
.and_then(|x| x.to_str())
.unwrap_or("未命名")
})
.into();
let updated = e
.metadata()
.map_err(|e| e.to_string())?
.modified()
.ok()
.map(|t| DateTime::<Utc>::from(t).to_rfc3339_opts(SecondsFormat::Millis, true))
.unwrap_or_else(|| "1970-01-01T00:00:00.000Z".into());
out.push(KnowledgeSummary {
path: rel,
title,
updated_at: updated,
sha256: sha(&b),
})
}
}
Ok(())
}
pub fn read_doc(app: &AppHandle, path: &str) -> Result<KnowledgeDocument, String> {
let rel = safe_rel(path)?;
let p = knowledge_root(app)?.join("docs").join(rel);
let b = fs::read(&p).map_err(|e| e.to_string())?;
let body = String::from_utf8(b.clone()).map_err(|_| "DOCUMENT_NOT_UTF8".to_string())?;
let title = body
.lines()
.find_map(|x| x.strip_prefix("# "))
.unwrap_or_else(|| p.file_stem().and_then(|x| x.to_str()).unwrap_or("未命名"))
.into();
Ok(KnowledgeDocument {
path: path.into(),
title,
body,
updated_at: now(),
sha256: sha(&b),
})
}
pub fn bridges(app: &AppHandle) -> Result<Vec<ExternalAiBridge>, String> {
let p = root(app)?.join("external-ai-bridges.json");
if p.exists() {
read_json(&p)
} else {
Ok(vec![])
}
}
pub fn save_bridges(app: &AppHandle, v: &[ExternalAiBridge]) -> Result<(), String> {
write_json(&root(app)?.join("external-ai-bridges.json"), &v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_timestamps_are_rfc3339_utc() {
let value = now();
assert!(value.ends_with('Z'));
assert!(DateTime::parse_from_rfc3339(&value).is_ok());
}
}