Skip to main content

codelens_engine/
project.rs

1use anyhow::{Context, Result, bail};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone)]
5pub struct ProjectRoot {
6    root: PathBuf,
7}
8
9const ROOT_MARKERS: &[&str] = &[
10    ".git",
11    ".codelens",
12    "build.gradle.kts",
13    "build.gradle",
14    "package.json",
15    "pyproject.toml",
16    "Cargo.toml",
17    "pom.xml",
18    "go.mod",
19];
20
21impl ProjectRoot {
22    /// Create a ProjectRoot, auto-detecting the actual root by walking up from
23    /// the given path until a root marker (.git, Cargo.toml, etc.) is found.
24    /// Falls back to the given path if no marker is found.
25    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
26        let start = path.as_ref().canonicalize().with_context(|| {
27            format!("failed to resolve project root {}", path.as_ref().display())
28        })?;
29        if !start.is_dir() {
30            bail!("project root is not a directory: {}", start.display());
31        }
32        let root = detect_root(&start).unwrap_or_else(|| start.clone());
33        Ok(Self { root })
34    }
35
36    /// Create a ProjectRoot at the exact given path without auto-detection.
37    pub fn new_exact(path: impl AsRef<Path>) -> Result<Self> {
38        let root = path.as_ref().canonicalize().with_context(|| {
39            format!("failed to resolve project root {}", path.as_ref().display())
40        })?;
41        if !root.is_dir() {
42            bail!("project root is not a directory: {}", root.display());
43        }
44        Ok(Self { root })
45    }
46
47    pub fn as_path(&self) -> &Path {
48        &self.root
49    }
50
51    pub fn resolve(&self, relative_or_absolute: impl AsRef<Path>) -> Result<PathBuf> {
52        let path = relative_or_absolute.as_ref();
53        let candidate = if path.is_absolute() {
54            path.to_path_buf()
55        } else {
56            self.root.join(path)
57        };
58        let normalized = normalize_path(&candidate);
59        if !normalized.starts_with(&self.root) {
60            bail!(
61                "path escapes project root: {} (root: {})",
62                normalized.display(),
63                self.root.display()
64            );
65        }
66        // If the path exists, verify the real (symlink-resolved) path also stays within root
67        if normalized.exists()
68            && let Ok(real) = normalized.canonicalize()
69            && !real.starts_with(&self.root)
70        {
71            bail!(
72                "symlink escapes project root: {} → {} (root: {})",
73                normalized.display(),
74                real.display(),
75                self.root.display()
76            );
77        }
78        // Resolve symlinks so the returned path matches what's stored in the index.
79        if normalized.exists()
80            && let Ok(real) = normalized.canonicalize()
81            && real.starts_with(&self.root)
82        {
83            return Ok(real);
84        }
85        Ok(normalized)
86    }
87
88    pub fn to_relative(&self, path: impl AsRef<Path>) -> String {
89        let path = path.as_ref();
90        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
91        canonical
92            .strip_prefix(&self.root)
93            .unwrap_or(&canonical)
94            .to_string_lossy()
95            .replace('\\', "/")
96    }
97}
98
99// ── Shared directory exclusion & file collection ────────────────────────
100
101pub const EXCLUDED_DIRS: &[&str] = &[
102    // VCS & IDE
103    ".git",
104    ".idea",
105    ".vscode",
106    ".cursor",
107    ".claude",
108    ".claire",
109    // Build output
110    ".gradle",
111    "build",
112    "dist",
113    "out",
114    "node_modules",
115    "vendor",
116    "__pycache__",
117    "target",
118    ".next",
119    // Virtual environments
120    ".venv",
121    "venv",
122    ".tox",
123    "env",
124    // Caches (common polluters — can contain 40K+ symbols from deps)
125    ".cache",
126    ".ruff_cache",
127    ".pytest_cache",
128    ".mypy_cache",
129    ".fastembed_cache",
130    // Editor extensions (e.g. Antigravity/Windsurf bundled JS)
131    ".antigravity",
132    ".windsurf",
133    // Cloud & external mounts
134    "Library",
135    // CodeLens runtime
136    ".codelens",
137];
138
139/// Returns `true` if any component of `path` matches an excluded directory.
140pub fn is_excluded(path: &Path) -> bool {
141    path.components().any(|component| {
142        let value = component.as_os_str().to_string_lossy();
143        EXCLUDED_DIRS.contains(&value.as_ref())
144    })
145}
146
147/// Walk `root` collecting files that pass `filter`, skipping excluded dirs.
148pub fn collect_files(root: &Path, filter: impl Fn(&Path) -> bool) -> Result<Vec<PathBuf>> {
149    use walkdir::WalkDir;
150    let mut files = Vec::new();
151    for entry in WalkDir::new(root)
152        .into_iter()
153        .filter_entry(|entry| !is_excluded(entry.path()))
154    {
155        let entry = entry?;
156        if entry.file_type().is_file() && filter(entry.path()) {
157            files.push(entry.path().to_path_buf());
158        }
159    }
160    Ok(files)
161}
162
163/// Walk `root` and return the canonical extension tag of the dominant
164/// source language by file count (e.g. `rs`, `py`, `ts`, `go`). Returns
165/// `None` when the project contains fewer than 3 source files in total,
166/// or when no single language holds a clear plurality.
167///
168/// v1.5 Phase 2j MCP follow-up. The engine helper walks the project
169/// once at activation time and hands the result to the MCP tool layer,
170/// which then exports `CODELENS_EMBED_HINT_AUTO_LANG=<lang>` so the
171/// engine's `auto_hint_should_enable` gate can consult
172/// `language_supports_nl_stack` on subsequent embedding calls.
173///
174/// Walk scope is capped (16 k files) to avoid pathological cases on
175/// very large monorepos — the goal is to classify the project by
176/// dominant language, not to enumerate every file. Directories in
177/// `EXCLUDED_DIRS` are skipped (same filter as `collect_files`). Only
178/// files with an extension recognised by the language registry are
179/// counted; build artefacts / README / Markdown are ignored.
180///
181/// The returned tag is the canonical extension string (e.g. `rs`,
182/// `py`) — exactly what `CODELENS_EMBED_HINT_AUTO_LANG` expects and
183/// what `crate::embedding::language_supports_nl_stack` accepts.
184pub fn compute_dominant_language(root: &Path) -> Option<String> {
185    use std::collections::HashMap;
186    use walkdir::WalkDir;
187
188    const WALK_CAP: usize = 16_384;
189    const MIN_FILES: usize = 3;
190
191    let mut counts: HashMap<String, usize> = HashMap::new();
192    let mut total = 0usize;
193
194    for entry in WalkDir::new(root)
195        .into_iter()
196        .filter_entry(|entry| !is_excluded(entry.path()))
197    {
198        let Ok(entry) = entry else {
199            continue;
200        };
201        if !entry.file_type().is_file() {
202            continue;
203        }
204        let Some(ext) = entry.path().extension() else {
205            continue;
206        };
207        let Some(ext_str) = ext.to_str() else {
208            continue;
209        };
210        let ext_lower = ext_str.to_ascii_lowercase();
211        // Only count extensions we know are source languages. This uses
212        // the language registry so future language additions stay in
213        // sync automatically. The import is local to avoid a cyclic
214        // module dependency with `lang_config`.
215        if crate::lang_registry::for_extension(&ext_lower).is_none() {
216            continue;
217        }
218        *counts.entry(ext_lower).or_insert(0) += 1;
219        total += 1;
220        if total >= WALK_CAP {
221            break;
222        }
223    }
224
225    if total < MIN_FILES {
226        return None;
227    }
228
229    // Find the extension with the highest count. A strict plurality is
230    // not required (return whichever wins) but the caller can use the
231    // count ratio via `compute_dominant_language_with_count` if they
232    // want to impose a threshold. For v1.5 Phase 2j we accept any
233    // plurality and let the downstream `language_supports_nl_stack`
234    // decide whether the tag maps to an allowed language.
235    counts
236        .into_iter()
237        .max_by_key(|(_, count)| *count)
238        .map(|(ext, _)| ext)
239}
240
241/// Walk up from `start` until a directory containing a root marker is found.
242fn detect_root(start: &Path) -> Option<PathBuf> {
243    let home = dirs_fallback();
244    let mut current = start.to_path_buf();
245    loop {
246        // `~/.codelens` stores global CodeLens state, so treating the home directory as an
247        // inferred project root causes unrelated folders to collapse onto `$HOME`.
248        // If the user really wants to operate on `$HOME`, they can pass it explicitly.
249        if current != start && Some(current.as_path()) == home.as_deref() {
250            break;
251        }
252        for marker in ROOT_MARKERS {
253            if current.join(marker).exists() {
254                return Some(current);
255            }
256        }
257        // Don't go above home directory
258        if Some(current.as_path()) == home.as_deref() {
259            break;
260        }
261        if !current.pop() {
262            break;
263        }
264    }
265    None
266}
267
268fn dirs_fallback() -> Option<PathBuf> {
269    std::env::var_os("HOME")
270        .map(PathBuf::from)
271        .map(|path| path.canonicalize().unwrap_or(path))
272}
273
274// ── Framework detection ─────────────────────────────────────────────────
275
276pub fn detect_frameworks(project: &Path) -> Vec<String> {
277    let mut frameworks = Vec::new();
278
279    // Python
280    if project.join("manage.py").exists() {
281        frameworks.push("django".into());
282    }
283    if has_dependency(project, "fastapi") {
284        frameworks.push("fastapi".into());
285    }
286    if has_dependency(project, "flask") {
287        frameworks.push("flask".into());
288    }
289
290    // JavaScript/TypeScript
291    if project.join("next.config.js").exists()
292        || project.join("next.config.mjs").exists()
293        || project.join("next.config.ts").exists()
294    {
295        frameworks.push("nextjs".into());
296    }
297    if has_node_dependency(project, "express") {
298        frameworks.push("express".into());
299    }
300    if has_node_dependency(project, "@nestjs/core") {
301        frameworks.push("nestjs".into());
302    }
303    if project.join("vite.config.ts").exists() || project.join("vite.config.js").exists() {
304        frameworks.push("vite".into());
305    }
306
307    // Rust
308    if project.join("Cargo.toml").exists() {
309        if has_cargo_dependency(project, "actix-web") {
310            frameworks.push("actix-web".into());
311        }
312        if has_cargo_dependency(project, "axum") {
313            frameworks.push("axum".into());
314        }
315        if has_cargo_dependency(project, "rocket") {
316            frameworks.push("rocket".into());
317        }
318    }
319
320    // Go
321    if has_go_dependency(project, "gin-gonic/gin") {
322        frameworks.push("gin".into());
323    }
324    if has_go_dependency(project, "gofiber/fiber") {
325        frameworks.push("fiber".into());
326    }
327
328    // Java/Kotlin
329    if has_gradle_or_maven_dependency(project, "spring-boot") {
330        frameworks.push("spring-boot".into());
331    }
332
333    frameworks
334}
335
336fn read_file_text(path: &Path) -> Option<String> {
337    std::fs::read_to_string(path).ok()
338}
339
340fn has_dependency(project: &Path, name: &str) -> bool {
341    let req = project.join("requirements.txt");
342    if let Some(text) = read_file_text(&req)
343        && text.contains(name)
344    {
345        return true;
346    }
347    let pyproject = project.join("pyproject.toml");
348    if let Some(text) = read_file_text(&pyproject)
349        && text.contains(name)
350    {
351        return true;
352    }
353    false
354}
355
356fn has_node_dependency(project: &Path, name: &str) -> bool {
357    let pkg = project.join("package.json");
358    if let Some(text) = read_file_text(&pkg) {
359        return text.contains(name);
360    }
361    false
362}
363
364fn has_cargo_dependency(project: &Path, name: &str) -> bool {
365    let cargo = project.join("Cargo.toml");
366    if let Some(text) = read_file_text(&cargo) {
367        return text.contains(name);
368    }
369    false
370}
371
372fn has_go_dependency(project: &Path, name: &str) -> bool {
373    let gomod = project.join("go.mod");
374    if let Some(text) = read_file_text(&gomod) {
375        return text.contains(name);
376    }
377    false
378}
379
380fn has_gradle_or_maven_dependency(project: &Path, name: &str) -> bool {
381    for file in &["build.gradle", "build.gradle.kts", "pom.xml"] {
382        if let Some(text) = read_file_text(&project.join(file))
383            && text.contains(name)
384        {
385            return true;
386        }
387    }
388    false
389}
390
391// ── Workspace/monorepo detection ────────────────────────────────────────
392
393#[derive(Debug, Clone, serde::Serialize)]
394pub struct WorkspacePackage {
395    pub name: String,
396    pub path: String,
397    pub package_type: String,
398}
399
400pub fn detect_workspace_packages(project: &Path) -> Vec<WorkspacePackage> {
401    let mut packages = Vec::new();
402
403    // Cargo workspace
404    let cargo_toml = project.join("Cargo.toml");
405    if cargo_toml.is_file()
406        && let Ok(content) = std::fs::read_to_string(&cargo_toml)
407        && content.contains("[workspace]")
408    {
409        for line in content.lines() {
410            let trimmed = line.trim().trim_matches('"').trim_matches(',');
411            if trimmed.contains("crates/") || trimmed.contains("packages/") {
412                let pattern = trimmed.trim_matches('"').trim_matches(',').trim();
413                if let Some(stripped) = pattern.strip_suffix("/*") {
414                    // Glob pattern: "crates/*" → scan directory
415                    let dir = project.join(stripped);
416                    if dir.is_dir() {
417                        for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
418                            if entry.path().join("Cargo.toml").is_file() {
419                                packages.push(WorkspacePackage {
420                                    name: entry.file_name().to_string_lossy().to_string(),
421                                    path: entry
422                                        .path()
423                                        .strip_prefix(project)
424                                        .unwrap_or(&entry.path())
425                                        .to_string_lossy()
426                                        .to_string(),
427                                    package_type: "cargo".to_string(),
428                                });
429                            }
430                        }
431                    }
432                } else {
433                    // Explicit path: "crates/codelens-core"
434                    let dir = project.join(pattern);
435                    if dir.join("Cargo.toml").is_file() {
436                        packages.push(WorkspacePackage {
437                            name: dir
438                                .file_name()
439                                .unwrap_or_default()
440                                .to_string_lossy()
441                                .to_string(),
442                            path: pattern.to_string(),
443                            package_type: "cargo".to_string(),
444                        });
445                    }
446                }
447            }
448        }
449    }
450
451    // npm workspace (package.json with "workspaces")
452    let pkg_json = project.join("package.json");
453    if pkg_json.is_file()
454        && let Ok(content) = std::fs::read_to_string(&pkg_json)
455        && content.contains("\"workspaces\"")
456    {
457        for dir_name in &["packages", "apps", "libs"] {
458            let dir = project.join(dir_name);
459            if dir.is_dir() {
460                for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
461                    if entry.path().join("package.json").is_file() {
462                        packages.push(WorkspacePackage {
463                            name: entry.file_name().to_string_lossy().to_string(),
464                            path: entry
465                                .path()
466                                .strip_prefix(project)
467                                .unwrap_or(&entry.path())
468                                .to_string_lossy()
469                                .to_string(),
470                            package_type: "npm".to_string(),
471                        });
472                    }
473                }
474            }
475        }
476    }
477
478    // Go workspace (go.work)
479    let go_work = project.join("go.work");
480    if go_work.is_file()
481        && let Ok(content) = std::fs::read_to_string(&go_work)
482    {
483        for line in content.lines() {
484            let trimmed = line.trim();
485            if !trimmed.starts_with("use")
486                && !trimmed.starts_with("go")
487                && !trimmed.starts_with("//")
488                && !trimmed.is_empty()
489                && trimmed != "("
490                && trimmed != ")"
491            {
492                let dir = project.join(trimmed);
493                if dir.join("go.mod").is_file() {
494                    packages.push(WorkspacePackage {
495                        name: trimmed.to_string(),
496                        path: trimmed.to_string(),
497                        package_type: "go".to_string(),
498                    });
499                }
500            }
501        }
502    }
503
504    packages
505}
506
507fn normalize_path(path: &Path) -> PathBuf {
508    let mut normalized = PathBuf::new();
509    for component in path.components() {
510        match component {
511            std::path::Component::CurDir => {}
512            std::path::Component::ParentDir => {
513                normalized.pop();
514            }
515            _ => normalized.push(component.as_os_str()),
516        }
517    }
518    normalized
519}
520
521#[cfg(test)]
522mod tests {
523    use super::{ProjectRoot, is_excluded};
524    use std::{
525        env, fs,
526        path::Path,
527        sync::{Mutex, OnceLock},
528    };
529
530    #[test]
531    fn excludes_agent_worktree_directories() {
532        // Regression guard: agent worktrees are copies of the source tree and
533        // must never appear in walks (dead_code, embedding, symbol indexing).
534        assert!(is_excluded(Path::new(
535            ".claire/worktrees/agent-abc/src/lib.rs"
536        )));
537        assert!(is_excluded(Path::new(
538            ".claude/worktrees/agent-xyz/main.rs"
539        )));
540        assert!(is_excluded(Path::new("project/.claire/anything.rs")));
541        // And the usual suspects stay excluded.
542        assert!(is_excluded(Path::new("node_modules/foo/index.js")));
543        assert!(is_excluded(Path::new("target/debug/build.rs")));
544        // Non-excluded paths should pass through.
545        assert!(!is_excluded(Path::new("crates/codelens-engine/src/lib.rs")));
546        assert!(!is_excluded(Path::new("src/claire_not_a_dir.rs")));
547    }
548
549    #[test]
550    fn rejects_path_escape() {
551        let dir = tempfile_dir();
552        let project = ProjectRoot::new(&dir).expect("project root");
553        let err = project
554            .resolve("../outside.txt")
555            .expect_err("should reject escape");
556        assert!(err.to_string().contains("escapes project root"));
557    }
558
559    #[test]
560    fn makes_relative_paths() {
561        let dir = tempfile_dir();
562        let nested = dir.join("src/lib.rs");
563        fs::create_dir_all(nested.parent().expect("parent")).expect("mkdir");
564        fs::write(&nested, "fn main() {}\n").expect("write file");
565
566        let project = ProjectRoot::new(&dir).expect("project root");
567        assert_eq!(project.to_relative(&nested), "src/lib.rs");
568    }
569
570    #[test]
571    fn does_not_promote_home_directory_from_global_codelens_marker() {
572        let _guard = env_lock().lock().expect("lock");
573        let home = tempfile_dir();
574        let nested = home.join("Downloads/codelens");
575        fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
576        fs::create_dir_all(&nested).expect("mkdir nested");
577
578        let previous_home = env::var_os("HOME");
579        unsafe {
580            env::set_var("HOME", &home);
581        }
582
583        let project = ProjectRoot::new(&nested).expect("project root");
584
585        match previous_home {
586            Some(value) => unsafe { env::set_var("HOME", value) },
587            None => unsafe { env::remove_var("HOME") },
588        }
589
590        assert_eq!(
591            project.as_path(),
592            nested.canonicalize().expect("canonical nested").as_path()
593        );
594    }
595
596    #[test]
597    fn still_detects_project_root_before_home_directory() {
598        let _guard = env_lock().lock().expect("lock");
599        let home = tempfile_dir();
600        let project_root = home.join("workspace/app");
601        let nested = project_root.join("src/features");
602        fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
603        fs::create_dir_all(&nested).expect("mkdir nested");
604        fs::write(
605            project_root.join("Cargo.toml"),
606            "[package]\nname = \"demo\"\n",
607        )
608        .expect("write cargo");
609
610        let previous_home = env::var_os("HOME");
611        unsafe {
612            env::set_var("HOME", &home);
613        }
614
615        let project = ProjectRoot::new(&nested).expect("project root");
616
617        match previous_home {
618            Some(value) => unsafe { env::set_var("HOME", value) },
619            None => unsafe { env::remove_var("HOME") },
620        }
621
622        assert_eq!(
623            project.as_path(),
624            project_root
625                .canonicalize()
626                .expect("canonical project root")
627                .as_path()
628        );
629    }
630
631    /// Unique per-test subdirectory inside `tempfile_dir()` to avoid
632    /// parallel-execution collisions on the nanosecond-timestamp path.
633    fn fresh_test_dir(label: &str) -> std::path::PathBuf {
634        let dir = tempfile_dir().join(label);
635        fs::create_dir_all(&dir).expect("mkdir fresh test dir");
636        dir
637    }
638
639    #[test]
640    fn compute_dominant_language_picks_rust_for_rust_heavy_project() {
641        let dir = fresh_test_dir("phase2j_rust_heavy");
642        // 5 Rust files, 1 Python file, 1 unknown extension file
643        fs::create_dir_all(dir.join("src")).expect("mkdir src");
644        fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("Cargo.toml");
645        for name in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"] {
646            fs::write(dir.join("src").join(name), "pub fn f() {}\n").expect("write rs");
647        }
648        fs::write(dir.join("scripts.py"), "def f():\n    pass\n").expect("write py");
649        fs::write(dir.join("README.md"), "# README\n").expect("write md");
650
651        let lang = super::compute_dominant_language(&dir).expect("dominant lang");
652        assert_eq!(lang, "rs", "expected rs dominant, got {lang}");
653    }
654
655    #[test]
656    fn compute_dominant_language_picks_python_for_python_heavy_project() {
657        let dir = fresh_test_dir("phase2j_python_heavy");
658        // 4 Python files, 1 Rust file
659        fs::create_dir_all(dir.join("pkg")).expect("mkdir pkg");
660        for name in ["mod_a.py", "mod_b.py", "mod_c.py", "mod_d.py"] {
661            fs::write(dir.join("pkg").join(name), "def f():\n    pass\n").expect("write py");
662        }
663        fs::write(dir.join("build.rs"), "fn main() {}\n").expect("write rs");
664
665        let lang = super::compute_dominant_language(&dir).expect("dominant lang");
666        assert_eq!(lang, "py", "expected py dominant, got {lang}");
667    }
668
669    #[test]
670    fn compute_dominant_language_returns_none_below_min_file_count() {
671        let dir = fresh_test_dir("phase2j_below_min");
672        // Only 2 source files (below MIN_FILES = 3)
673        fs::write(dir.join("only.rs"), "fn x() {}\n").expect("write rs");
674        fs::write(dir.join("other.py"), "def y(): pass\n").expect("write py");
675
676        let lang = super::compute_dominant_language(&dir);
677        assert!(lang.is_none(), "expected None below 3 files, got {lang:?}");
678    }
679
680    #[test]
681    fn compute_dominant_language_skips_excluded_dirs() {
682        let dir = fresh_test_dir("phase2j_excluded_dirs");
683        fs::create_dir_all(dir.join("src")).expect("mkdir src");
684        fs::create_dir_all(dir.join("node_modules/foo")).expect("mkdir node_modules");
685        fs::create_dir_all(dir.join("target")).expect("mkdir target");
686        // 3 real Rust source files
687        for name in ["a.rs", "b.rs", "c.rs"] {
688            fs::write(dir.join("src").join(name), "fn f() {}\n").expect("write src rs");
689        }
690        // 10 fake JS files inside node_modules that must be skipped
691        for i in 0..10 {
692            fs::write(
693                dir.join("node_modules/foo").join(format!("x{i}.js")),
694                "module.exports = {};\n",
695            )
696            .expect("write node_modules js");
697        }
698        // 10 fake build artefacts in target/ that must be skipped
699        for i in 0..10 {
700            fs::write(
701                dir.join("target").join(format!("build{i}.rs")),
702                "fn f() {}\n",
703            )
704            .expect("write target rs");
705        }
706
707        let lang = super::compute_dominant_language(&dir).expect("dominant lang");
708        // Only the 3 src/*.rs files should be counted — not the 10
709        // node_modules JS files and not the 10 target build artefacts.
710        assert_eq!(lang, "rs", "expected rs from src only, got {lang}");
711    }
712
713    fn env_lock() -> &'static Mutex<()> {
714        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
715        LOCK.get_or_init(|| Mutex::new(()))
716    }
717
718    fn tempfile_dir() -> std::path::PathBuf {
719        let dir = std::env::temp_dir().join(format!(
720            "codelens-core-project-{}",
721            std::time::SystemTime::now()
722                .duration_since(std::time::UNIX_EPOCH)
723                .expect("time")
724                .as_nanos()
725        ));
726        fs::create_dir_all(&dir).expect("create tempdir");
727        dir
728    }
729}