use cyberbrain_core::{NoteKind, Result, Ring};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub struct Proposed {
pub dir: String,
pub files: usize,
pub bytes: u64,
pub kind: NoteKind,
pub bereich: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Survey {
pub root: String,
pub groups: Vec<Proposed>,
pub skipped: BTreeMap<String, usize>,
pub total_files: usize,
}
fn kind_for(name: &str) -> NoteKind {
let n = name.to_lowercase();
let has = |words: &[&str]| words.iter().any(|w| n.contains(w));
if has(&["regel", "rule", "policy", "richtlinie", "vorgabe"]) {
NoteKind::Decision
} else if has(&[
"anleitung",
"howto",
"how-to",
"guide",
"handbuch",
"manual",
]) {
NoteKind::Reference
} else if has(&[
"vorfall",
"vorfäll",
"vorfael",
"incident",
"stoerung",
"störung",
"postmortem",
"panne",
]) {
NoteKind::Bug
} else if has(&["lehre", "lesson", "erfahrung"]) {
NoteKind::Lesson
} else {
NoteKind::Knowledge
}
}
fn bereich_for(name: &str) -> Option<String> {
let n = name.trim();
if n.is_empty() || n.contains(' ') || n.contains('/') {
return None;
}
let generic = [
"docs",
"doc",
"notes",
"notizen",
"wiki",
"dokumente",
"documents",
"allgemein",
"misc",
"sonstiges",
"temp",
"tmp",
"archiv",
"archive",
];
if generic.contains(&n.to_lowercase().as_str()) {
return None;
}
if kind_for(n) != NoteKind::Knowledge {
return None;
}
cyberbrain_core::frontmatter::validate_bereich(n).ok()?;
Some(n.to_string())
}
pub fn survey(root: &Path) -> Result<Survey> {
let mut groups: BTreeMap<String, (usize, u64)> = BTreeMap::new();
let mut skipped: BTreeMap<String, usize> = BTreeMap::new();
let mut total = 0usize;
let mut walk = |dir: &Path, label: &str| -> Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
continue;
}
total += 1;
let ext = p
.extension()
.and_then(|s| s.to_str())
.unwrap_or("(no extension)")
.to_lowercase();
if ext != "md" && ext != "markdown" {
*skipped.entry(ext).or_default() += 1;
continue;
}
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
let g = groups.entry(label.to_string()).or_insert((0, 0));
g.0 += 1;
g.1 += size;
}
Ok(())
};
walk(root, "")?;
if let Ok(entries) = std::fs::read_dir(root) {
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let Some(name) = p.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
walk(&p, name)?;
}
}
let out = groups
.into_iter()
.filter(|(_, (files, _))| *files > 0)
.map(|(dir, (files, bytes))| Proposed {
kind: kind_for(&dir),
bereich: if dir.is_empty() {
None
} else {
bereich_for(&dir)
},
dir,
files,
bytes,
})
.collect();
Ok(Survey {
root: root.display().to_string(),
groups: out,
skipped,
total_files: total,
})
}
pub fn to_plan(s: &Survey) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Proposed by `cyberbrain import --suggest {}`.\n\
#\n\
# Read it before you run it. Every `kind` and every `bereich` below is a guess from\n\
# a folder name — the tool has not read a single file. What it is sure of is how\n\
# many files it found and which ones it cannot use.\n\
#\n\
# Everything is ring 2. Rings 0 and 1 are yours: an invariant does not come from a\n\
# folder nobody has read yet.\n\
#\n\
# A group without a `bereich` is imported and never shared with a hub. That is the\n\
# safe default; add one where a folder really belongs to a department.\n\n",
s.root
));
out.push_str("version = 1\n");
out.push_str(&format!("root = {:?}\n", s.root));
out.push_str("tags = [\"imported\"]\n\n");
if !s.skipped.is_empty() {
let parts: Vec<String> = s
.skipped
.iter()
.map(|(ext, n)| format!("{n}× .{ext}"))
.collect();
let globs: Vec<String> = s
.skipped
.keys()
.filter(|e| *e != "(no extension)")
.map(|e| format!("\"**/*.{e}\""))
.collect();
out.push_str(&format!("# Found and left alone: {}\n", parts.join(", ")));
if !globs.is_empty() {
out.push_str(&format!(
"[[skip]]\npaths = [{}]\nreason = \"not Markdown\"\n\n",
globs.join(", ")
));
}
}
for g in &s.groups {
let label = if g.dir.is_empty() {
"top-level"
} else {
&g.dir
};
let paths = if g.dir.is_empty() {
"\"*.md\", \"*.markdown\"".to_string()
} else {
format!("\"{}/*.md\", \"{}/*.markdown\"", g.dir, g.dir)
};
out.push_str(&format!(
"# {} file(s), {} KB\n[[group]]\nname = {:?}\npaths = [{}]\nring = 2\nkind = {:?}\n",
g.files,
g.bytes / 1024,
label,
paths,
kind_str(g.kind),
));
if !g.dir.is_empty() {
out.push_str("note_name = \"{dir}-{stem}\"\ncollisions = \"hash\"\n");
}
match &g.bereich {
Some(b) => out.push_str(&format!(
"# Guessed from the folder name. Wrong here means these notes reach the wrong\n\
# department, so check it.\nbereich = {b:?}\n"
)),
None => out.push_str(
"# No bereich: these stay on this machine. Add one to share them.\n\
# bereich = \"…\"\n",
),
}
out.push('\n');
}
if s.groups.is_empty() {
out.push_str("# No Markdown found. Nothing to propose.\n");
}
out
}
fn kind_str(k: NoteKind) -> String {
serde_json::to_value(k)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "knowledge".into())
}
pub fn summary(s: &Survey) -> String {
let files: usize = s.groups.iter().map(|g| g.files).sum();
let with_bereich = s.groups.iter().filter(|g| g.bereich.is_some()).count();
let mut t = format!(
"{} Markdown file(s) in {} group(s), out of {} file(s) in {}.\n",
files,
s.groups.len(),
s.total_files,
s.root
);
if !s.skipped.is_empty() {
let parts: Vec<String> = s
.skipped
.iter()
.map(|(ext, n)| format!("{n}× .{ext}"))
.collect();
t.push_str(&format!(
"Not Markdown and left alone: {}.\n",
parts.join(", ")
));
}
t.push_str(&format!(
"{with_bereich} group(s) got a suggested bereich from the folder name; the rest stay \
on this machine until you give them one.\n\
Nothing has been imported. Save the plan, read it, then run \
`cyberbrain import --plan <file>`.\n"
));
t
}
const _: Option<Ring> = None;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_kind_name_is_not_a_department() {
for n in ["Anleitungen", "Regeln", "Vorfaelle", "howto", "policy"] {
assert_eq!(bereich_for(n), None, "{n} was proposed as a bereich");
}
for n in ["Disposition", "HR", "Vertrieb"] {
assert_eq!(bereich_for(n).as_deref(), Some(n), "{n} should be one");
}
}
#[test]
fn a_generic_folder_gets_no_bereich() {
for n in ["docs", "Notizen", "wiki", "misc", "Archiv", "with space"] {
assert_eq!(bereich_for(n), None, "{n}");
}
}
#[test]
fn folder_names_suggest_a_kind() {
assert_eq!(kind_for("Regeln"), NoteKind::Decision);
assert_eq!(kind_for("Anleitungen"), NoteKind::Reference);
assert_eq!(kind_for("Vorfaelle"), NoteKind::Bug);
assert_eq!(kind_for("Disposition"), NoteKind::Knowledge);
}
#[test]
fn the_skip_rule_never_swallows_everything() {
let s = Survey {
root: "/x".into(),
groups: vec![Proposed {
dir: "Disposition".into(),
files: 2,
bytes: 100,
kind: NoteKind::Knowledge,
bereich: Some("Disposition".into()),
}],
skipped: [("pdf".to_string(), 1)].into_iter().collect(),
total_files: 3,
};
let plan = to_plan(&s);
assert!(
!plan.contains(r#"paths = ["**/*"]"#),
"catch-all skip is back"
);
assert!(plan.contains(r#""**/*.pdf""#));
}
#[test]
fn a_proposal_never_reaches_the_resident_rings() {
let s = Survey {
root: "/x".into(),
groups: vec![Proposed {
dir: "Regeln".into(),
files: 1,
bytes: 10,
kind: NoteKind::Decision,
bereich: None,
}],
skipped: Default::default(),
total_files: 1,
};
let plan = to_plan(&s);
assert!(plan.contains("ring = 2"));
assert!(!plan.contains("ring = 0"));
assert!(!plan.contains("ring = 1"));
}
}