use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
use crate::error::{Error, Result};
use crate::model::Task;
use crate::storage;
pub fn derive_title(path: &Path, content: &str) -> String {
for line in content.lines() {
let line = line.trim();
if let Some(h) = line.strip_prefix("# ") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
}
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
strip_date_prefix(&stem).trim_matches(['-', '_', ' ']).to_string()
}
fn strip_date_prefix(stem: &str) -> &str {
if let Some(prefix) = stem.get(..10)
&& prefix.parse::<NaiveDate>().is_ok() {
return &stem[10..];
}
stem
}
pub fn derive_created_at(path: &Path) -> DateTime<Utc> {
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
if let Some(prefix) = stem.get(..10)
&& let Ok(date) = prefix.parse::<NaiveDate>()
&& let Some(dt) = chrono::Local
.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
.single()
{
return dt.to_utc();
}
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(DateTime::<Utc>::from)
.unwrap_or_else(|_| Utc::now())
}
pub fn adopt_file(root: &Path, path: &Path) -> Result<Task> {
let content = std::fs::read_to_string(path)?;
if storage::parse_task(&content).is_ok() {
return Err(Error::InvalidTaskFile(format!(
"{} is already a task file",
path.display()
)));
}
let seq = storage::LibraryMeta::allocate_seq(root)?;
let mut task = Task::new(seq, derive_title(path, &content));
task.meta.created_at = derive_created_at(path);
task.body = content;
storage::write_task(path, &task)?;
Ok(task)
}
pub struct AdoptArgs {
pub paths: Vec<String>,
pub all: bool,
pub dry_run: bool,
pub rename: bool,
}
pub fn run(args: AdoptArgs) -> Result<()> {
let root = super::task::active_library_root()?;
let candidates: Vec<PathBuf> = if args.paths.is_empty() {
storage::load_library(&root)?.skipped
} else {
args.paths
.iter()
.map(|p| {
let path = PathBuf::from(p);
if path.is_absolute() { path } else { root.join(path) }
})
.collect()
};
if candidates.is_empty() {
println!("nothing to adopt");
return Ok(());
}
let mut adopted = 0usize;
let mut adopt_rest = args.all;
for path in &candidates {
if !path.exists() {
eprintln!("warning: {} does not exist, skipping", path.display());
continue;
}
if args.dry_run {
let content = std::fs::read_to_string(path)?;
println!(
"would adopt: {} (title: \"{}\")",
path.display(),
derive_title(path, &content)
);
continue;
}
if !adopt_rest {
match prompt(&format!("adopt {}?", path.display()))? {
Choice::Yes => {}
Choice::No => continue,
Choice::All => adopt_rest = true,
Choice::Quit => break,
}
}
let task = adopt_file(&root, path)?;
println!("adopted #{} [{}] {}", task.meta.seq, task.short_id(), task.meta.title);
if args.rename {
let st = storage::StoredTask { path: path.clone(), task };
super::task::sync_filename(&root, &st)?;
}
adopted += 1;
}
if !args.dry_run {
println!("adopted {adopted} file(s)");
}
Ok(())
}
enum Choice {
Yes,
No,
All,
Quit,
}
fn prompt(msg: &str) -> Result<Choice> {
print!("{msg} [y/N/a(ll)/q(uit)] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(match input.trim().to_lowercase().as_str() {
"y" | "yes" => Choice::Yes,
"a" | "all" => Choice::All,
"q" | "quit" => Choice::Quit,
_ => Choice::No,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_from_heading() {
let p = Path::new("2026-06-17-langchain研究.md");
assert_eq!(derive_title(p, "intro\n\n# LangChain 研究笔记\n"), "LangChain 研究笔记");
}
#[test]
fn title_from_filename_strips_date() {
let p = Path::new("2026-06-17-langchain研究.md");
assert_eq!(derive_title(p, "no heading here"), "langchain研究");
let p = Path::new("plain-note.md");
assert_eq!(derive_title(p, ""), "plain-note");
}
#[test]
fn created_at_from_filename_date() {
let p = Path::new("2026-06-17-note.md");
let dt = derive_created_at(p);
let local = dt.with_timezone(&chrono::Local);
assert_eq!(local.format("%Y-%m-%d").to_string(), "2026-06-17");
}
#[test]
fn multibyte_filename_does_not_panic() {
let p = Path::new("数据笔记研究记录.md");
assert_eq!(derive_title(p, ""), "数据笔记研究记录");
let _ = derive_created_at(p);
}
#[test]
fn adopt_preserves_body_and_derives_meta() {
let dir = std::env::temp_dir().join(format!("tasks-adopt-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("2026-06-17-研究.md");
let original = "# 研究笔记\n\n一些内容\n";
std::fs::write(&file, original).unwrap();
let task = adopt_file(&dir, &file).unwrap();
assert_eq!(task.meta.title, "研究笔记");
assert_eq!(task.body, original);
assert_eq!(task.meta.seq, 1);
let loaded = storage::read_task(&file).unwrap();
assert_eq!(loaded.meta.title, "研究笔记");
assert_eq!(loaded.body, original);
assert!(adopt_file(&dir, &file).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}