package main import ( "bufio" "fmt" "os" "path" "path/filepath" "regexp" "sort" "strings" "time" "github.com/spf13/cobra" "github.com/runbear-io/beardrive/internal/config" "github.com/runbear-io/beardrive/internal/journal" "github.com/runbear-io/beardrive/internal/store" "github.com/runbear-io/beardrive/internal/syncer" ) // staleLinkRe matches a markdown inline link's target: [label](target). var staleLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)`) // staleWikiRe matches Obsidian-style [[target]] and [[target|label]] links. // Copied from internal/webapp/markdown.go rather than shared: importing the // server package into a local read command to save one line is the wrong // trade. var staleWikiRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) // stalePathRe matches a bare path-shaped token — at least one slash, and no // wrapping punctuation, so a backticked `cmd/bdrive/grep.go` yields the path // and not the backticks. Resolution is the real filter, so this stays loose. var stalePathRe = regexp.MustCompile(`[A-Za-z0-9._~@+-]+(?:/[A-Za-z0-9._~@+-]+)+`) // staleSchemeRe matches a URL scheme, so https:// and mailto: never resolve. var staleSchemeRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`) func staleCmd() *cobra.Command { var ( filesOnly bool limit int ) c := &cobra.Command{ Use: "stale [folder]", Short: "Find docs whose code has moved on since they were written", Long: `Report synced markdown that references files written after the doc itself. Staleness here is not age: a doc is outgrown when a file it links to has a newer last-write time than the doc. Only the files this project actually syncs are scanned — a .bdriveignore rule or a narrowed ` + "`bdrive scope`" + ` excludes a file from this command exactly as it excludes it from sync. Write times come from the local journal, not from the filesystem: materialize stamps a peer's file with THIS device's mtime, so on a freshly synced machine every mtime is the same and only the journal still knows when each file was really written. It is a pure read with no daemon, no lock, and no network, so it works offline and never blocks on a sync in progress. Exit status is 0 whether or not anything is stale — this is advisory output, not a gate.`, Example: ` bdrive stale # every outgrown doc, with the references that aged it bdrive stale -l # paths only, one per line bdrive stale -n 5 # the five worst`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runStale(cmd, args, filesOnly, limit) }, } c.Flags().BoolVarP(&filesOnly, "files-with-matches", "l", false, "print outgrown paths only, one per line") // -n means the same here as in `bdrive log` and `bdrive grep`: max rows out. c.Flags().IntVarP(&limit, "limit", "n", 50, "max docs printed (0 = all)") return c } // staleRef is one reference that has outrun its doc. type staleRef struct { path string gap time.Duration } // staleDoc is one outgrown doc and the references that aged it, worst first. type staleDoc struct { path string refs []staleRef } func runStale(cmd *cobra.Command, folderArg []string, filesOnly bool, limit int) error { folder, err := absFolder(folderArg) if err != nil { return err } // LoadProject, not ResolveMount: ResolveMount self-heals the registry // path, i.e. it enrolls this device. A read-only query must not have that // side effect — the same rule grep follows. proj, found, err := config.LoadProject(folder) if err != nil { return err } if !found { return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder) } // The accepted rules and the journal both come from the volume store, // opened the way grep opens it: Stat-guarded, because store.Open MkdirAlls // and a read must not create a volume for a project that has never synced, // and unlocked, because store.Open takes no volume flock — a running // daemon never blocks this. var ( accepted string ops []journal.Op ) if vdir, verr := config.VolumeDir(proj.ID); verr == nil && dirExists(vdir) { if st, serr := store.Open(vdir); serr == nil { if sync, serr := st.LoadSync(); serr == nil { accepted = sync.IgnoreAccepted } if all, serr := st.AllOps(); serr == nil { ops = all } } } paths, err := syncer.SyncedFiles(folder, proj.Include, accepted) if err != nil { return err } out := cmd.OutOrStdout() written := staleWriteTimes(ops) if len(written) == 0 { fmt.Fprintln(out, "no history yet") return nil } synced := make(map[string]bool, len(paths)) for _, rel := range paths { synced[rel] = true } var docs []staleDoc for _, rel := range paths { if !isMarkdownPath(rel) { continue } docTime, ok := written[rel] if !ok { continue // never synced: nothing to date it by } var refs []staleRef for _, ref := range staleRefs(filepath.Join(folder, rel), rel, synced) { refTime, ok := written[ref] if !ok || !refTime.After(docTime) { continue } refs = append(refs, staleRef{path: ref, gap: refTime.Sub(docTime)}) } if len(refs) == 0 { continue } sort.Slice(refs, func(i, j int) bool { return refs[i].gap > refs[j].gap }) docs = append(docs, staleDoc{path: rel, refs: refs}) } // Worst first: the doc with the reference that has outrun it furthest. sort.SliceStable(docs, func(i, j int) bool { return docs[i].refs[0].gap > docs[j].refs[0].gap }) total := 0 for _, d := range docs { total += len(d.refs) } shown := docs truncated := false if limit > 0 && len(shown) > limit { shown, truncated = shown[:limit], true } for _, d := range shown { // Every path here is a string a teammate chose — a file name, or text // inside a synced doc. safeField, or a lone CR repaints the row and // U+202E reverses it. name := safeField(d.path, 160) if filesOnly { fmt.Fprintln(out, name) continue } fmt.Fprintf(out, "%-40s %-16s (oldest gap %s)\n", name, plural(len(d.refs), "file")+" newer", staleGap(d.refs[0].gap)) for _, r := range d.refs { fmt.Fprintf(out, " %-38s %s newer\n", safeField(r.path, 160), staleGap(r.gap)) } } if !filesOnly { if len(docs) == 0 { fmt.Fprintln(out, "no outgrown docs") } else { fmt.Fprintf(out, "\n%s, %s\n", plural(len(docs), "outgrown doc"), plural(total, "stale reference")) } } if truncated { fmt.Fprintf(out, "output limited to %s — use -n 0 for all\n", plural(limit, "doc")) } // Exit 0 either way. grep's "1 means nothing found" convention inverts // here — it would fail on a clean project — and this is advisory in the // same sense the agent hook's context is: nothing is blocked by it. return nil } // staleWriteTimes dates every path from the journal, newest write wins. // // Max by DisplayTime, not the newest op under journal.Less: DisplayTime is // what `bdrive log` sorts by, and it returns the zero time for an op stamped // in the future — so taking the causally-newest op would date that path to // year 1 and flag every doc referencing it. Max discards the zero naturally. func staleWriteTimes(ops []journal.Op) map[string]time.Time { written := make(map[string]time.Time, len(ops)) for _, op := range ops { if op.Kind != journal.KindPut { continue } t := syncer.DisplayTime(op) if t.IsZero() { continue // an op we cannot date does not get to date a path } if cur, ok := written[op.Path]; !ok || t.After(cur) { written[op.Path] = t } } return written } func isMarkdownPath(rel string) bool { switch strings.ToLower(path.Ext(rel)) { case ".md", ".markdown": return true } return false } // staleRefs returns the synced paths one doc references, deduped. Unreadable // files are skipped, never fatal, the same posture the scan takes. func staleRefs(abs, rel string, synced map[string]bool) []string { f, err := os.Open(abs) if err != nil { return nil } defer f.Close() docDir := path.Dir(rel) seen := map[string]bool{} var refs []string keep := func(cand string) { target, ok := resolveRef(docDir, cand, synced) if !ok || target == rel || seen[target] { return } seen[target] = true refs = append(refs, target) } // grep's bounded scanner: a minified file that happens to be named .md // must not be buffered whole. sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64<<10), maxLineScan) for sc.Scan() { line := sc.Text() for _, m := range staleLinkRe.FindAllStringSubmatch(line, -1) { keep(m[1]) } for _, m := range staleWikiRe.FindAllStringSubmatch(line, -1) { // A wikilink names a doc, usually without its extension. keep(m[1]) keep(m[1] + ".md") } for _, m := range stalePathRe.FindAllString(line, -1) { keep(m) } } return refs // sc.Err() ignored: an over-long line ends this file, not the run } // resolveRef turns one candidate string into a synced path, or drops it. // Resolution IS the filter: anything that does not land on a file this project // syncs is not a reference, so a loose extractor upstream costs nothing. func resolveRef(docDir, cand string, synced map[string]bool) (string, bool) { cand = strings.TrimSpace(cand) // A trailing anchor or query is not part of the path. if i := strings.IndexAny(cand, "#?"); i >= 0 { cand = cand[:i] } cand = strings.TrimRight(cand, `.,;:!?"'`) if cand == "" || strings.HasPrefix(cand, "/") || staleSchemeRe.MatchString(cand) { return "", false // absolute, protocol-relative (//host), or a URL } tries := []string{path.Clean(cand)} if docDir != "." { tries = append([]string{path.Join(docDir, cand)}, tries...) } for _, p := range tries { // Never leave the mount, and never name the root itself. if p == "." || p == "/" || strings.HasPrefix(p, "../") || strings.HasPrefix(p, "/") { continue } if synced[p] { return p, true } } return "", false } // staleGap renders how far a reference has outrun its doc. Sub-day gaps read // as <1d rather than 0d, which would look like no gap at all. func staleGap(d time.Duration) string { days := int(d.Hours() / 24) if days < 1 { return "<1d" } return fmt.Sprintf("%dd", days) }