# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739561880 18000
#      Fri Feb 14 14:38:00 2025 -0500
# Node ID 879029f033246646dca9553a381cd01670c5e4f2
# Parent  d81714a1c88d5143d3999afaa6e4eab4f32f590e
# EXP-Topic annotate-wdir
rust-config: add username parsing

This adds Config::username which returns HGUSER, ui.username, or EMAIL in that
order, similar to ui.username() in Python.

I considered following the pattern of EDITOR, VISUAL, PAGER, etc. and using
add_for_environment_variable, but it's not possible to get the same precendence
as in Python that way (in particular HGUSER coming after the repo .hg/hgrc), at
least not without significant changes.

This will be used for 'rhg annotate -r wdir() -u' to annotate the username on
lines that were changed in the working directory.

diff --git a/rust/hg-core/src/config/mod.rs b/rust/hg-core/src/config/mod.rs
--- a/rust/hg-core/src/config/mod.rs
+++ b/rust/hg-core/src/config/mod.rs
@@ -23,6 +23,7 @@
 use self::layer::ConfigValue;
 use crate::errors::HgError;
 use crate::errors::{HgResultExt, IoResultExt};
+use crate::exit_codes;
 use crate::utils::files::get_bytes_from_os_str;
 use format_bytes::{write_bytes, DisplayBytes};
 use std::collections::HashSet;
@@ -841,6 +842,32 @@
             _ => None,
         }
     }
+
+    /// Returns the default username to be used in commits. Like ui.username()
+    /// in Python with acceptempty=False, but aborts rather than prompting for
+    /// input or falling back to the OS username and hostname.
+    pub fn username(&self) -> Result<Vec<u8>, HgError> {
+        if let Some(value) = env::var_os("HGUSER") {
+            if !value.is_empty() {
+                return Ok(value.into_encoded_bytes());
+            }
+        }
+        if let Some(value) = self.get_str(b"ui", b"username")? {
+            if !value.is_empty() {
+                return Ok(value.as_bytes().to_vec());
+            }
+        }
+        if let Some(value) = env::var_os("EMAIL") {
+            if !value.is_empty() {
+                return Ok(value.into_encoded_bytes());
+            }
+        }
+        Err(HgError::abort(
+            "no username supplied",
+            exit_codes::ABORT,
+            Some("use 'hg config --edit' to set your username".to_string()),
+        ))
+    }
 }
 
 /// Corresponds to `usage.resources[.<dimension>]`.
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739983578 18000
#      Wed Feb 19 11:46:18 2025 -0500
# Node ID 1a99e0c2d6d50793a6f70ca2e791fe99f83147d4
# Parent  879029f033246646dca9553a381cd01670c5e4f2
# EXP-Topic annotate-wdir
rust-revlog: add RevisionOrWdir

This type represents either a checked Revision or the wdir() pseudo-revision
(revision 0x7fffffff, node ffffffffffffffffffffffffffffffffffffffff).

You construct it with revision.into() or Revision::wdir(), and destructure it
with rev.exclude_wdir() which returns Option<Revision>.

I considered something like `enum RevisionOrWdir { Wdir, Revision(Revision) }`,
but decided on `struct RevisionOrWdir(BaseRevision)` for a few reasons:

- It's more ergonomic for the ways it actually gets used, in my opinion.
- It also avoids the possibility of an invalid value Revision(0x7fffffff).
- It remains 4 bytes rather than 8.
- It maintains the ordering: wdir is greater than all other revisions.

I'm planning to use this for 'rhg annotate -r wdir()'.

diff --git a/rust/hg-core/src/revlog/mod.rs b/rust/hg-core/src/revlog/mod.rs
--- a/rust/hg-core/src/revlog/mod.rs
+++ b/rust/hg-core/src/revlog/mod.rs
@@ -126,6 +126,39 @@
 pub const WORKING_DIRECTORY_HEX: &str =
     "ffffffffffffffffffffffffffffffffffffffff";
 
+/// Either a checked revision or the working directory.
+/// Note that [`Revision`] will never hold [`WORKING_DIRECTORY_REVISION`]
+/// because that is not a valid revision in any revlog.
+#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub struct RevisionOrWdir(BaseRevision);
+
+impl From<Revision> for RevisionOrWdir {
+    fn from(value: Revision) -> Self {
+        RevisionOrWdir(value.0)
+    }
+}
+
+impl RevisionOrWdir {
+    /// Creates a [`RevisionOrWdir`] representing the working directory.
+    pub fn wdir() -> Self {
+        RevisionOrWdir(WORKING_DIRECTORY_REVISION.0)
+    }
+
+    /// Returns the revision, or `None` if this is the working directory.
+    pub fn exclude_wdir(self) -> Option<Revision> {
+        if self.0 == WORKING_DIRECTORY_REVISION.0 {
+            None
+        } else {
+            Some(Revision(self.0))
+        }
+    }
+
+    /// Returns true if this is the working directory.
+    pub fn is_wdir(&self) -> bool {
+        *self == Self::wdir()
+    }
+}
+
 /// The simplest expression of what we need of Mercurial DAGs.
 pub trait Graph {
     /// Return the two parents of the given `Revision`.
@@ -974,4 +1007,10 @@
             }
         };
     }
+
+    #[test]
+    fn test_revision_or_wdir_ord() {
+        let highest: RevisionOrWdir = Revision(i32::MAX - 1).into();
+        assert!(highest < RevisionOrWdir::wdir());
+    }
 }
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739983934 18000
#      Wed Feb 19 11:52:14 2025 -0500
# Node ID bb30c89f1ffbb1f789d04c0939bb272d98922548
# Parent  1a99e0c2d6d50793a6f70ca2e791fe99f83147d4
# EXP-Topic annotate-wdir
rust-revset: support resolving wdir()

This makes revset::resolve_single return RevisionOrWdir. Previously, it returned
RevlogError::WDirUnsupported (leading to abort, not fallback) for 2147483647 and
ffffffffffffffffffffffffffffffffffffffff. It did not recognize 'wdir()' itself,
so that would lead to Python fallback. Now, it treats all 3 cases the same: it
returns RevisionOrWdir::wdir() and lets the caller decide what to do.

I changed rhg cat, files, and annotate to return HgError::unsupported in this
case, since wdir is valid. I made `rhg status --change wdir()` behave the same
as `rhg status`, conforming to the test in test-status.t.

diff --git a/rust/hg-core/src/operations/cat.rs b/rust/hg-core/src/operations/cat.rs
--- a/rust/hg-core/src/operations/cat.rs
+++ b/rust/hg-core/src/operations/cat.rs
@@ -83,7 +83,11 @@
     revset: &str,
     mut files: Vec<&'a HgPath>,
 ) -> Result<CatOutput<'a>, RevlogError> {
-    let rev = crate::revset::resolve_single(revset, repo)?;
+    let Some(rev) =
+        crate::revset::resolve_single(revset, repo)?.exclude_wdir()
+    else {
+        return Err(HgError::unsupported("cat wdir not implemented").into());
+    };
     let manifest = repo.manifest_for_rev(rev.into())?;
     let mut results: Vec<(&'a HgPath, Vec<u8>)> = vec![];
     let node = *repo.changelog()?.node_from_rev(rev);
diff --git a/rust/hg-core/src/operations/list_tracked_files.rs b/rust/hg-core/src/operations/list_tracked_files.rs
--- a/rust/hg-core/src/operations/list_tracked_files.rs
+++ b/rust/hg-core/src/operations/list_tracked_files.rs
@@ -22,8 +22,12 @@
     revset: &str,
     narrow_matcher: Box<dyn Matcher + Sync>,
 ) -> Result<FilesForRev, RevlogError> {
-    let rev = crate::revset::resolve_single(revset, repo)?;
-    list_rev_tracked_files(repo, rev.into(), narrow_matcher)
+    match crate::revset::resolve_single(revset, repo)?.exclude_wdir() {
+        Some(rev) => list_rev_tracked_files(repo, rev.into(), narrow_matcher),
+        None => {
+            Err(HgError::unsupported("list wdir files not implemented").into())
+        }
+    }
 }
 
 /// List files under Mercurial control at a given revision.
diff --git a/rust/hg-core/src/revset.rs b/rust/hg-core/src/revset.rs
--- a/rust/hg-core/src/revset.rs
+++ b/rust/hg-core/src/revset.rs
@@ -4,10 +4,10 @@
 
 use crate::errors::HgError;
 use crate::repo::Repo;
-use crate::revlog::NodePrefix;
+use crate::revlog::{NodePrefix, RevisionOrWdir};
 use crate::revlog::{Revision, NULL_REVISION, WORKING_DIRECTORY_HEX};
 use crate::revlog::{Revlog, RevlogError};
-use crate::Node;
+use crate::{Node, WORKING_DIRECTORY_REVISION};
 
 /// Resolve a query string into a single revision.
 ///
@@ -15,19 +15,20 @@
 pub fn resolve_single(
     input: &str,
     repo: &Repo,
-) -> Result<Revision, RevlogError> {
+) -> Result<RevisionOrWdir, RevlogError> {
     let changelog = repo.changelog()?;
 
     match input {
         "." => {
             let p1 = repo.dirstate_parents()?.p1;
-            return changelog.revlog.rev_from_node(p1.into());
+            return Ok(changelog.revlog.rev_from_node(p1.into())?.into());
         }
-        "null" => return Ok(NULL_REVISION),
+        "null" => return Ok(NULL_REVISION.into()),
+        "wdir()" => return Ok(RevisionOrWdir::wdir()),
         _ => {}
     }
 
-    match resolve_rev_number_or_hex_prefix(input, &changelog.revlog) {
+    match resolve(input, &changelog.revlog) {
         Err(RevlogError::InvalidRevision(revision)) => {
             // TODO: support for the rest of the language here.
             let msg = format!("cannot parse revset '{}'", revision);
@@ -41,31 +42,42 @@
 /// the changelog, such as in `hg debugdata --manifest` CLI argument.
 ///
 /// * A non-negative decimal integer for a revision number, or
-/// * An hexadecimal string, for the unique node ID that starts with this
-///   prefix
+/// * A hexadecimal string, for the unique node ID that starts with this prefix
 pub fn resolve_rev_number_or_hex_prefix(
     input: &str,
     revlog: &Revlog,
 ) -> Result<Revision, RevlogError> {
+    match resolve(input, revlog)?.exclude_wdir() {
+        Some(rev) => Ok(rev),
+        None => Err(RevlogError::WDirUnsupported),
+    }
+}
+
+fn resolve(
+    input: &str,
+    revlog: &Revlog,
+) -> Result<RevisionOrWdir, RevlogError> {
     // The Python equivalent of this is part of `revsymbol` in
     // `mercurial/scmutil.py`
-
     if let Ok(integer) = input.parse::<i32>() {
-        if integer.to_string() == input
-            && integer >= 0
-            && revlog.has_rev(integer.into())
-        {
-            // This is fine because we've just checked that the revision is
-            // valid for the given revlog.
-            return Ok(Revision(integer));
+        if integer.to_string() == input && integer >= 0 {
+            if integer == WORKING_DIRECTORY_REVISION.0 {
+                return Ok(RevisionOrWdir::wdir());
+            }
+            if revlog.has_rev(integer.into()) {
+                // This is fine because we've just checked that the revision is
+                // valid for the given revlog.
+                return Ok(Revision(integer).into());
+            }
         }
     }
     if let Ok(prefix) = NodePrefix::from_hex(input) {
-        if prefix.is_prefix_of(&Node::from_hex(WORKING_DIRECTORY_HEX).unwrap())
-        {
-            return Err(RevlogError::WDirUnsupported);
+        let wdir_node =
+            Node::from_hex(WORKING_DIRECTORY_HEX).expect("wdir hex is valid");
+        if prefix.is_prefix_of(&wdir_node) {
+            return Ok(RevisionOrWdir::wdir());
         }
-        return revlog.rev_from_node(prefix);
+        return Ok(revlog.rev_from_node(prefix)?.into());
     }
     Err(RevlogError::InvalidRevision(input.to_string()))
 }
diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -151,6 +151,11 @@
 
     let rev = args.get_one::<String>("rev").expect("rev has a default");
     let rev = hg::revset::resolve_single(rev, repo)?;
+    let Some(rev) = rev.exclude_wdir() else {
+        return Err(CommandError::unsupported(
+            "annotate wdir not implemented",
+        ));
+    };
 
     let files = match args.get_many::<OsString>("files") {
         None => vec![],
diff --git a/rust/rhg/src/commands/status.rs b/rust/rhg/src/commands/status.rs
--- a/rust/rhg/src/commands/status.rs
+++ b/rust/rhg/src/commands/status.rs
@@ -25,7 +25,7 @@
 use hg::repo::Repo;
 use hg::revlog::manifest::Manifest;
 use hg::revlog::options::{default_revlog_options, RevlogOpenOptions};
-use hg::revlog::RevlogType;
+use hg::revlog::{RevisionOrWdir, RevlogError, RevlogType};
 use hg::utils::debug::debug_wait_for_file;
 use hg::utils::files::{
     get_bytes_from_os_str, get_bytes_from_os_string, get_path_from_bytes,
@@ -171,12 +171,15 @@
     let Some(revs) = revs else {
         return Ok(None);
     };
+    let resolve = |input| match hg::revset::resolve_single(input, repo)?
+        .exclude_wdir()
+    {
+        Some(rev) => Ok(rev),
+        None => Err(RevlogError::WDirUnsupported),
+    };
     match revs.as_slice() {
         [] => Ok(None),
-        [rev1, rev2] => Ok(Some((
-            hg::revset::resolve_single(rev1, repo)?,
-            hg::revset::resolve_single(rev2, repo)?,
-        ))),
+        [rev1, rev2] => Ok(Some((resolve(rev1)?, resolve(rev2)?))),
         _ => Err(CommandError::unsupported("expected 0 or 2 --rev flags")),
     }
 }
@@ -312,6 +315,8 @@
     let change = change
         .map(|rev| hg::revset::resolve_single(rev, repo))
         .transpose()?;
+    // Treat `rhg status --change wdir()` the same as `rhg status`.
+    let change = change.and_then(RevisionOrWdir::exclude_wdir);
 
     if verbose && has_unfinished_state(repo)? {
         return Err(CommandError::unsupported(
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739906391 18000
#      Tue Feb 18 14:19:51 2025 -0500
# Node ID 97acc8d0b44c0baf00399b121f30b135d5b1df97
# Parent  bb30c89f1ffbb1f789d04c0939bb272d98922548
# EXP-Topic annotate-wdir
rust-annotate: prefix abort messages with "abort:"

This matches Python behavior and the other uses of HgError::abort.

I noticed this difference in a test failure when working on 'wdir()' support.

diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -191,7 +191,7 @@
     }
     if include.line_number && !(include.number || include.changeset) {
         return Err(CommandError::abort(
-            "at least one of -n/-c is required for -l",
+            "abort: at least one of -n/-c is required for -l",
         ));
     }
 
@@ -234,7 +234,7 @@
             AnnotateOutput::NotFound => {
                 let short = changelog.node_from_rev(rev).short();
                 return Err(CommandError::abort(format!(
-                    "{path}: no such file in rev {short:x}",
+                    "abort: {path}: no such file in rev {short:x}",
                 )));
             }
         }
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739907306 18000
#      Tue Feb 18 14:35:06 2025 -0500
# Node ID 26187392976b19f426fabed3c3aac22ae6a12c91
# Parent  97acc8d0b44c0baf00399b121f30b135d5b1df97
# EXP-Topic annotate-wdir
annotate: add test for file not found

We already test hg annotate -r wdir() after removing a file with "rm" and
"hg rm". This tests the case where the file was never in the repo to begin with.

diff --git a/tests/test-annotate.t b/tests/test-annotate.t
--- a/tests/test-annotate.t
+++ b/tests/test-annotate.t
@@ -706,6 +706,12 @@
   abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !)
   [255]
 
+annotate file neither in repo nor working copy
+
+  $ hg annotate -ncr "wdir()" never_existed
+  abort: never_existed: $ENOENT$
+  [10]
+
   $ hg revert --all --no-backup --quiet
   $ hg id -n
   20
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1741016709 18000
#      Mon Mar 03 10:45:09 2025 -0500
# Node ID 6243e76af3dda67a2bc1fcb413bb790a3de78d2d
# Parent  26187392976b19f426fabed3c3aac22ae6a12c91
# EXP-Topic annotate-wdir
annotate: use exit code 255 for missing files

This makes hg annotate always use exit code 255 for missing files. Before, it
used 10 (input error) if the file was never in the repo, and 255 (abort) if it
is in the repo for some other revision.

The motivation for this is to match the behavior of hg.py and rhg. This
discrepancy was revealed by using "hg annotate" instead of "hg ann" in
test-annotate.t, since rhg doesn't recognize "ann" and falls back.

diff --git a/mercurial/commands.py b/mercurial/commands.py
--- a/mercurial/commands.py
+++ b/mercurial/commands.py
@@ -571,7 +571,7 @@
     )
 
     def bad(x, y):
-        raise error.InputError(b"%s: %s" % (x, y))
+        raise error.Abort(b"%s: %s" % (x, y))
 
     m = scmutil.match(ctx, pats, opts, badfn=bad)
 
diff --git a/tests/test-annotate.t b/tests/test-annotate.t
--- a/tests/test-annotate.t
+++ b/tests/test-annotate.t
@@ -516,9 +516,9 @@
 
 missing file
 
-  $ hg ann nosuchfile
+  $ hg annotate nosuchfile
   abort: nosuchfile: no such file in rev e9e6b4fa872f
-  [10]
+  [255]
 
 annotate file without '\n' on last line
 
@@ -710,7 +710,7 @@
 
   $ hg annotate -ncr "wdir()" never_existed
   abort: never_existed: $ENOENT$
-  [10]
+  [255]
 
   $ hg revert --all --no-backup --quiet
   $ hg id -n
diff --git a/tests/test-fastannotate-hg.t b/tests/test-fastannotate-hg.t
--- a/tests/test-fastannotate-hg.t
+++ b/tests/test-fastannotate-hg.t
@@ -456,9 +456,9 @@
 
 missing file
 
-  $ hg ann nosuchfile
+  $ hg annotate nosuchfile
   abort: nosuchfile: no such file in rev e9e6b4fa872f
-  [10]
+  [255]
 
 annotate file without '\n' on last line
 
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739909758 18000
#      Tue Feb 18 15:15:58 2025 -0500
# Node ID 0a18c2c7ac35c077cefd77912ab8a09609d146b8
# Parent  6243e76af3dda67a2bc1fcb413bb790a3de78d2d
# EXP-Topic annotate-wdir
rust: refactor operations::annotate in preparation for wdir

The main change is to add a RepoState type which bundles together a few things
that are passed around a lot. The wdir() change adds dirstate parents to this.

diff --git a/rust/hg-core/src/operations/annotate.rs b/rust/hg-core/src/operations/annotate.rs
--- a/rust/hg-core/src/operations/annotate.rs
+++ b/rust/hg-core/src/operations/annotate.rs
@@ -1,5 +1,3 @@
-use std::borrow::Cow;
-
 use crate::{
     bdiff::{self, Lines},
     errors::HgError,
@@ -20,6 +18,7 @@
 use itertools::Itertools as _;
 use rayon::prelude::*;
 use self_cell::self_cell;
+use std::{borrow::Cow, cell::Ref};
 
 /// Options for [`annotate`].
 #[derive(Copy, Clone)]
@@ -101,6 +100,33 @@
     line_number: u32,
 }
 
+/// [`Repo`] and related objects that often need to be passed together.
+struct RepoState<'a> {
+    repo: &'a Repo,
+    changelog: Ref<'a, Changelog>,
+    manifestlog: Ref<'a, Manifestlog>,
+}
+
+impl<'a> RepoState<'a> {
+    fn new(repo: &'a Repo, include_dirstate: bool) -> Result<Self, HgError> {
+        let changelog = repo.changelog()?;
+        let manifestlog = repo.manifestlog()?;
+        Ok(Self {
+            repo,
+            changelog,
+            manifestlog,
+        })
+    }
+
+    fn dirstate_parents(&self) -> [Revision; 2] {
+        self.dirstate_parents.expect("should be set for wdir")
+    }
+
+    fn dirstate_map(&'a self) -> &'a OwningDirstateMap {
+        self.dirstate_map.as_ref().expect("should be set for wdir")
+    }
+}
+
 /// Helper for keeping track of multiple filelogs.
 #[derive(Default)]
 struct FilelogSet {
@@ -150,7 +176,7 @@
         Ok(index)
     }
 
-    /// Opens a new filelog by path and returns the id for the given file node.
+    /// Opens a filelog by path and returns the id for the given file node.
     fn open_at_node(
         &mut self,
         repo: &Repo,
@@ -163,9 +189,44 @@
         Ok(FileId { index, revision })
     }
 
+    /// Opens a filelog by path and returns the id for the given changelog
+    /// revision. Returns `None` if no filelog exists for that path.
+    fn open_at_changelog_rev(
+        &mut self,
+        state: &RepoState,
+        path: &HgPath,
+        changelog_revision: Revision,
+    ) -> Result<Option<FileId>, HgError> {
+        let changelog_data =
+            state.changelog.entry(changelog_revision)?.data()?;
+        let manifest = state
+            .manifestlog
+            .data_for_node(changelog_data.manifest_node()?.into())?;
+        let Some(entry) = manifest.find_by_path(path)? else {
+            return Ok(None);
+        };
+        let node = entry.node_id()?;
+        Ok(Some(self.open_at_node(state.repo, path, node)?))
+    }
+
+    /// Opens and reads a file by path at a changelog revision, returning its
+    /// id and contents. Returns `None` if not found.
+    fn open_and_read(
+        &mut self,
+        state: &RepoState,
+        path: &HgPath,
+        revision: Revision,
+    ) -> Result<Option<(FileId, Vec<u8>)>, HgError> {
+        match self.open_at_changelog_rev(state, path, revision)? {
+            None => Ok(None),
+            Some(id) => Ok(Some((id, self.read(id)?))),
+        }
+    }
+
     /// Reads the contents of a file by id.
-    fn read(&self, id: FileId) -> Result<FilelogRevisionData, HgError> {
-        self.get(id.index).filelog.entry(id.revision)?.data()
+    fn read(&self, id: FileId) -> Result<Vec<u8>, HgError> {
+        let filelog = &self.get(id.index).filelog;
+        filelog.entry(id.revision)?.data()?.into_file_data()
     }
 
     /// Returns the parents of a file. If `follow_copies` is true, it treats
@@ -173,7 +234,7 @@
     /// (since it has to read the file to extract the copy metadata).
     fn parents(
         &mut self,
-        repo: &Repo,
+        state: &RepoState,
         id: FileId,
         follow_copies: bool,
     ) -> Result<(Vec<FileId>, Option<Vec<u8>>), HgError> {
@@ -195,7 +256,7 @@
             // If copy or copyrev occurs without the other, ignore it.
             // This matches filerevisioncopied in storageutil.py.
             if let (Some(copy), Some(copyrev)) = (meta.copy, meta.copyrev) {
-                parents.push(self.open_at_node(repo, copy, copyrev)?);
+                parents.push(self.open_at_node(state.repo, copy, copyrev)?);
             }
             file_data = Some(data.into_file_data()?);
         }
@@ -275,19 +336,13 @@
     options: AnnotateOptions,
 ) -> Result<AnnotateOutput, HgError> {
     // Step 1: Load the base file and check if it's binary.
-    let changelog = repo.changelog()?;
-    let manifestlog = repo.manifestlog()?;
+    let state = RepoState::new(repo, changelog_revision.is_wdir())?;
     let mut fls = FilelogSet::default();
-    let base_id = {
-        let changelog_data = changelog.entry(changelog_revision)?.data()?;
-        let manifest = manifestlog
-            .data_for_node(changelog_data.manifest_node()?.into())?;
-        let Some(entry) = manifest.find_by_path(path)? else {
-            return Ok(AnnotateOutput::NotFound);
-        };
-        fls.open_at_node(repo, path, entry.node_id()?)?
+    let Some((base_id, base_file_data)) =
+        fls.open_and_read(&state, path, changelog_revision)?
+    else {
+        return Ok(AnnotateOutput::NotFound);
     };
-    let base_file_data = fls.read(base_id)?.into_file_data()?;
     if !options.treat_binary_as_text
         && utils::files::is_binary(&base_file_data)
     {
@@ -303,7 +358,7 @@
             continue;
         }
         let (parents, file_data) =
-            fls.parents(repo, id, options.follow_copies)?;
+            fls.parents(&state, id, options.follow_copies)?;
         info.parents = Some(parents.clone());
         if let Some(data) = file_data {
             info.file = AnnotatedFileState::Read(OwnedLines::split(
@@ -337,7 +392,7 @@
         |(&id, info)| -> Result<(), HgError> {
             if let AnnotatedFileState::None = info.file {
                 info.file = AnnotatedFileState::Read(OwnedLines::split(
-                    fls.read(id)?.into_file_data()?,
+                    fls.read(id)?,
                     options.whitespace,
                 )?);
             }
@@ -411,7 +466,7 @@
     }
     // Use the same object for all ancestor checks, since it internally
     // builds a hash set of seen revisions.
-    let mut ancestors = ancestor_iter(&changelog, changelog_revision, None);
+    let mut ancestors = ancestor_iter(&state, changelog_revision, None);
     // Iterate in reverse topological order so that we visits nodes after their
     // children, that way we can propagate `descendant` correctly.
     for &id in topological_order.iter().rev() {
@@ -422,8 +477,7 @@
             ChangelogRevisionState::NotNeeded => descendant,
             ChangelogRevisionState::Needed => {
                 let revision = adjust_link_revision(
-                    &changelog,
-                    &manifestlog,
+                    &state,
                     &fls,
                     &mut ancestors,
                     descendant,
@@ -487,13 +541,13 @@
 
 /// Creates an iterator over the ancestors of `base_revision` (inclusive),
 /// stopping at `stop_revision` if provided. Panics if `base_revision` is null.
-fn ancestor_iter(
-    changelog: &Changelog,
+fn ancestor_iter<'a>(
+    state: &'a RepoState<'a>,
     base_revision: Revision,
     stop_revision: Option<Revision>,
-) -> AncestorsIterator<&Changelog> {
+) -> AncestorsIterator<&'a Changelog> {
     AncestorsIterator::new(
-        changelog,
+        &*state.changelog,
         [base_revision],
         stop_revision.unwrap_or(NULL_REVISION),
         true,
@@ -504,8 +558,7 @@
 /// If the linkrev of `id` is in `ancestors`, returns it. Otherwise, finds and
 /// returns the first ancestor of `descendant` that introduced `id`.
 fn adjust_link_revision(
-    changelog: &Changelog,
-    manifestlog: &Manifestlog,
+    state: &RepoState<'_>,
     fls: &FilelogSet,
     ancestors: &mut AncestorsIterator<&Changelog>,
     descendant: Revision,
@@ -514,19 +567,21 @@
     let FilelogSetItem { filelog, path } = fls.get(id.index);
     let linkrev = filelog
         .revlog
-        .link_revision(id.revision, &changelog.revlog)?;
+        .link_revision(id.revision, &state.changelog.revlog)?;
     if ancestors.contains(linkrev).map_err(from_graph_error)? {
         return Ok(linkrev);
     }
     let file_node = *filelog.revlog.node_from_rev(id.revision);
-    for ancestor in ancestor_iter(changelog, descendant, Some(linkrev)) {
+    for ancestor in ancestor_iter(state, descendant, Some(linkrev)) {
         let ancestor = ancestor.map_err(from_graph_error)?;
-        let data = changelog.entry(ancestor)?.data()?;
+        let data = state.changelog.entry(ancestor)?.data()?;
         if data.files().contains(&path.as_ref()) {
-            let manifest_rev = manifestlog
+            let manifest_rev = state
+                .manifestlog
                 .revlog
                 .rev_from_node(data.manifest_node()?.into())?;
-            if let Some(entry) = manifestlog
+            if let Some(entry) = state
+                .manifestlog
                 .inexact_data_delta_parents(manifest_rev)?
                 .find_by_path(path)?
             {
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739913669 18000
#      Tue Feb 18 16:21:09 2025 -0500
# Node ID 3dc7a34aa03ea9182f74791aec0fb4c9081ff15d
# Parent  0a18c2c7ac35c077cefd77912ab8a09609d146b8
# EXP-Topic annotate-wdir
rust: refactor commands::annotate in preparation for wdir

The main change is to add a FormatterConfig type.

diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -1,15 +1,17 @@
 use core::str;
-use std::{collections::hash_map::Entry, ffi::OsString};
+use std::{cell::Ref, collections::hash_map::Entry, ffi::OsString};
 
+use chrono::{DateTime, FixedOffset};
 use format_bytes::format_bytes;
 use hg::{
     encoding::Encoder,
     operations::{
         annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation,
     },
+    repo::Repo,
     revlog::changelog::Changelog,
     utils::strings::CleanWhitespace,
-    FastHashMap, Revision,
+    FastHashMap, Node, Revision,
 };
 
 use crate::{error::CommandError, utils::path_utils::resolve_file_args};
@@ -204,11 +206,10 @@
 
     let changelog = repo.changelog()?;
     let mut formatter = Formatter::new(
-        &changelog,
+        repo,
         invocation.ui.encoder(),
-        include,
-        verbosity,
-    );
+        FormatterConfig { include, verbosity },
+    )?;
     let mut stdout = invocation.ui.stdout_buffer();
     for path in files {
         match annotate(repo, &path, rev, options)? {
@@ -245,14 +246,17 @@
 }
 
 struct Formatter<'a> {
-    changelog: &'a Changelog,
+    changelog: Ref<'a, Changelog>,
     encoder: &'a Encoder,
-    include: Include,
-    verbosity: Verbosity,
+    config: FormatterConfig,
     cache: FastHashMap<Revision, ChangesetData>,
 }
 
-#[derive(Copy, Clone)]
+struct FormatterConfig {
+    include: Include,
+    verbosity: Verbosity,
+}
+
 struct Include {
     user: bool,
     number: bool,
@@ -274,7 +278,6 @@
     }
 }
 
-#[derive(Copy, Clone)]
 enum Verbosity {
     Quiet,
     Default,
@@ -292,52 +295,60 @@
     fn create(
         revision: Revision,
         changelog: &Changelog,
-        include: Include,
-        verbosity: Verbosity,
-    ) -> Result<ChangesetData, CommandError> {
-        let mut result = ChangesetData::default();
+        config: &FormatterConfig,
+    ) -> Result<Self, CommandError> {
+        let include = &config.include;
         if !(include.user || include.changeset || include.date) {
-            return Ok(result);
+            return Ok(Self::default());
         }
         let entry = changelog.entry(revision)?;
         let data = entry.data()?;
-        if include.user {
-            let user = match verbosity {
-                Verbosity::Verbose => data.user(),
-                _ => hg::utils::strings::short_user(data.user()),
+        let node = *entry.as_revlog_entry().node();
+        Ok(Self::new(data.user(), node, data.timestamp()?, config))
+    }
+
+    fn new(
+        user: &[u8],
+        changeset: Node,
+        date: DateTime<FixedOffset>,
+        config: &FormatterConfig,
+    ) -> Self {
+        let mut result = ChangesetData::default();
+        if config.include.user {
+            let user = match config.verbosity {
+                Verbosity::Verbose => user,
+                _ => hg::utils::strings::short_user(user),
             };
             result.user = Some(user.to_vec());
         }
-        if include.changeset {
-            let changeset = entry.as_revlog_entry().node().short();
-            result.changeset = Some(format!("{:x}", changeset).into_bytes());
+        if config.include.changeset {
+            result.changeset =
+                Some(format!("{:x}", changeset.short()).into_bytes());
         }
-        if include.date {
-            let date = data.timestamp()?.format(match verbosity {
+        if config.include.date {
+            let date = date.format(match config.verbosity {
                 Verbosity::Quiet => "%Y-%m-%d",
                 _ => "%a %b %d %H:%M:%S %Y %z",
             });
             result.date = Some(format!("{}", date).into_bytes());
         }
-        Ok(result)
+        result
     }
 }
 
 impl<'a> Formatter<'a> {
     fn new(
-        changelog: &'a Changelog,
+        repo: &'a Repo,
         encoder: &'a Encoder,
-        include: Include,
-        verbosity: Verbosity,
-    ) -> Self {
-        let cache = FastHashMap::default();
-        Self {
+        config: FormatterConfig,
+    ) -> Result<Self, CommandError> {
+        let changelog = repo.changelog()?;
+        Ok(Self {
             changelog,
             encoder,
-            include,
-            verbosity,
-            cache,
-        }
+            config,
+            cache: FastHashMap::default(),
+        })
     }
 
     fn format(
@@ -346,7 +357,7 @@
     ) -> Result<Vec<Vec<u8>>, CommandError> {
         let mut lines: Vec<Vec<Vec<u8>>> =
             Vec::with_capacity(annotations.len());
-        let num_fields = self.include.count();
+        let num_fields = self.config.include.count();
         let mut widths = vec![0usize; num_fields];
         for annotation in annotations {
             let revision = annotation.revision;
@@ -354,16 +365,15 @@
                 Entry::Occupied(occupied) => occupied.into_mut(),
                 Entry::Vacant(vacant) => vacant.insert(ChangesetData::create(
                     revision,
-                    self.changelog,
-                    self.include,
-                    self.verbosity,
+                    &self.changelog,
+                    &self.config,
                 )?),
             };
             let mut fields = Vec::with_capacity(num_fields);
             if let Some(user) = &data.user {
                 fields.push(user.clone());
             }
-            if self.include.number {
+            if self.config.include.number {
                 fields.push(format_bytes!(b"{}", revision));
             }
             if let Some(changeset) = &data.changeset {
@@ -372,10 +382,10 @@
             if let Some(date) = &data.date {
                 fields.push(date.clone());
             }
-            if self.include.file {
+            if self.config.include.file {
                 fields.push(annotation.path.into_vec());
             }
-            if self.include.line_number {
+            if self.config.include.line_number {
                 fields.push(format_bytes!(b"{}", annotation.line_number));
             }
             for (field, width) in fields.iter().zip(widths.iter_mut()) {
@@ -395,8 +405,8 @@
                     fields.iter().zip(widths.iter()).enumerate()
                 {
                     if i > 0 {
-                        let colon =
-                            self.include.line_number && i == num_fields - 1;
+                        let colon = self.config.include.line_number
+                            && i == num_fields - 1;
                         bytes.push(if colon { b':' } else { b' ' });
                     }
                     let padding =
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1739909763 18000
#      Tue Feb 18 15:16:03 2025 -0500
# Node ID 08ee687dbf3df3bd28db016a0d163d1d6e7d29a0
# Parent  3dc7a34aa03ea9182f74791aec0fb4c9081ff15d
# EXP-Topic annotate-wdir
rust-annotate: support --rev wdir()

This adds support for annotating a file in wdir(). Lines that are changed in the
working directory get annotated with dirstate.p1 followed by "+", as in Python.

I had to change some test-annotate.t output for an edge case. If file "foo" does
not exist, `hg annotate -r wdir() foo` prints a slightly different error message
depending on whether "foo" was ever previously tracked. I don't think this is
useful or done purposefully, so it doesn't seem worth complicating rhg annotate
to behave in the same way.

diff --git a/rust/hg-core/src/operations/annotate.rs b/rust/hg-core/src/operations/annotate.rs
--- a/rust/hg-core/src/operations/annotate.rs
+++ b/rust/hg-core/src/operations/annotate.rs
@@ -1,11 +1,11 @@
 use crate::{
     bdiff::{self, Lines},
+    dirstate::{owning::OwningDirstateMap, DirstateError},
     errors::HgError,
     repo::Repo,
     revlog::{
-        changelog::Changelog,
-        filelog::{Filelog, FilelogRevisionData},
-        manifest::Manifestlog,
+        changelog::Changelog, filelog::Filelog, manifest::Manifestlog,
+        RevisionOrWdir,
     },
     utils::{
         self,
@@ -52,7 +52,7 @@
     /// file's current path if it was copied or renamed in the past.
     pub path: HgPathBuf,
     /// The changelog revision that introduced the line.
-    pub revision: Revision,
+    pub revision: RevisionOrWdir,
     /// The one-based line number in the original file.
     pub line_number: u32,
 }
@@ -105,16 +105,30 @@
     repo: &'a Repo,
     changelog: Ref<'a, Changelog>,
     manifestlog: Ref<'a, Manifestlog>,
+    dirstate_parents: Option<[Revision; 2]>,
+    dirstate_map: Option<Ref<'a, OwningDirstateMap>>,
 }
 
 impl<'a> RepoState<'a> {
     fn new(repo: &'a Repo, include_dirstate: bool) -> Result<Self, HgError> {
         let changelog = repo.changelog()?;
         let manifestlog = repo.manifestlog()?;
+        let (dirstate_parents, dirstate_map) = if include_dirstate {
+            let crate::DirstateParents { p1, p2 } = repo.dirstate_parents()?;
+            let p1 = changelog.rev_from_node(p1.into())?;
+            let p2 = changelog.rev_from_node(p2.into())?;
+            let dirstate_map =
+                repo.dirstate_map().map_err(from_dirstate_error)?;
+            (Some([p1, p2]), Some(dirstate_map))
+        } else {
+            (None, None)
+        };
         Ok(Self {
             repo,
             changelog,
             manifestlog,
+            dirstate_parents,
+            dirstate_map,
         })
     }
 
@@ -128,6 +142,7 @@
 }
 
 /// Helper for keeping track of multiple filelogs.
+/// Also abstracts over reading from filelogs and from the working directory.
 #[derive(Default)]
 struct FilelogSet {
     /// List of filelogs. The first one is for the root file being blamed.
@@ -147,7 +162,15 @@
 
 /// Identifies a file revision in a FilelogSet.
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
-struct FileId {
+enum FileId {
+    /// The file in the working directory.
+    Wdir,
+    /// A revision of the file in a filelog.
+    Rev(RevFileId),
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+struct RevFileId {
     index: FilelogIndex,
     revision: Revision,
 }
@@ -182,11 +205,11 @@
         repo: &Repo,
         path: &HgPath,
         node: Node,
-    ) -> Result<FileId, HgError> {
+    ) -> Result<RevFileId, HgError> {
         let index = self.open(repo, path)?;
         let revision =
             self.get(index).filelog.revlog.rev_from_node(node.into())?;
-        Ok(FileId { index, revision })
+        Ok(RevFileId { index, revision })
     }
 
     /// Opens a filelog by path and returns the id for the given changelog
@@ -196,7 +219,7 @@
         state: &RepoState,
         path: &HgPath,
         changelog_revision: Revision,
-    ) -> Result<Option<FileId>, HgError> {
+    ) -> Result<Option<RevFileId>, HgError> {
         let changelog_data =
             state.changelog.entry(changelog_revision)?.data()?;
         let manifest = state
@@ -209,22 +232,32 @@
         Ok(Some(self.open_at_node(state.repo, path, node)?))
     }
 
-    /// Opens and reads a file by path at a changelog revision, returning its
-    /// id and contents. Returns `None` if not found.
+    /// Opens and reads a file by path at a changelog revision (or working
+    /// directory), returning its id and contents. Returns `None` if not found.
     fn open_and_read(
         &mut self,
         state: &RepoState,
         path: &HgPath,
-        revision: Revision,
+        revision: RevisionOrWdir,
     ) -> Result<Option<(FileId, Vec<u8>)>, HgError> {
-        match self.open_at_changelog_rev(state, path, revision)? {
-            None => Ok(None),
-            Some(id) => Ok(Some((id, self.read(id)?))),
+        match revision.exclude_wdir() {
+            Some(revision) => {
+                match self.open_at_changelog_rev(state, path, revision)? {
+                    None => Ok(None),
+                    Some(id) => Ok(Some((FileId::Rev(id), self.read(id)?))),
+                }
+            }
+            None => {
+                let fs_path = utils::hg_path::hg_path_to_path_buf(path)?;
+                let maybe_data =
+                    state.repo.working_directory_vfs().try_read(fs_path)?;
+                Ok(maybe_data.map(|data| (FileId::Wdir, data)))
+            }
         }
     }
 
     /// Reads the contents of a file by id.
-    fn read(&self, id: FileId) -> Result<Vec<u8>, HgError> {
+    fn read(&self, id: RevFileId) -> Result<Vec<u8>, HgError> {
         let filelog = &self.get(id.index).filelog;
         filelog.entry(id.revision)?.data()?.into_file_data()
     }
@@ -235,19 +268,36 @@
     fn parents(
         &mut self,
         state: &RepoState,
+        base_path: &HgPath,
         id: FileId,
         follow_copies: bool,
     ) -> Result<(Vec<FileId>, Option<Vec<u8>>), HgError> {
+        let mut parents = Vec::<FileId>::with_capacity(2);
+        let FileId::Rev(id) = id else {
+            // If a file in the working directory is copied/renamed, its parent
+            // is the copy source (just as it will be after committing).
+            let path = state
+                .dirstate_map()
+                .copy_map_get(base_path)?
+                .unwrap_or(base_path);
+            for rev in state.dirstate_parents() {
+                if let Some(id) =
+                    self.open_at_changelog_rev(state, path, rev)?
+                {
+                    parents.push(FileId::Rev(id));
+                }
+            }
+            return Ok((parents, None));
+        };
         let filelog = &self.get(id.index).filelog;
         let revisions =
             filelog.parents(id.revision).map_err(from_graph_error)?;
-        let mut parents = Vec::with_capacity(2);
         let mut file_data = None;
         if revisions[0] != NULL_REVISION {
-            parents.push(FileId {
+            parents.push(FileId::Rev(RevFileId {
                 index: id.index,
                 revision: revisions[0],
-            });
+            }));
         } else if follow_copies {
             // A null p1 indicates there might be copy metadata.
             // Check for it, and if present use it as the parent.
@@ -256,15 +306,17 @@
             // If copy or copyrev occurs without the other, ignore it.
             // This matches filerevisioncopied in storageutil.py.
             if let (Some(copy), Some(copyrev)) = (meta.copy, meta.copyrev) {
-                parents.push(self.open_at_node(state.repo, copy, copyrev)?);
+                parents.push(FileId::Rev(
+                    self.open_at_node(state.repo, copy, copyrev)?,
+                ));
             }
             file_data = Some(data.into_file_data()?);
         }
         if revisions[1] != NULL_REVISION {
-            parents.push(FileId {
+            parents.push(FileId::Rev(RevFileId {
                 index: id.index,
                 revision: revisions[1],
-            });
+            }));
         }
         Ok((parents, file_data))
     }
@@ -274,6 +326,8 @@
 #[derive(Default)]
 struct FileInfo {
     /// Parents of this revision (via p1 and p2 or copy metadata).
+    /// These are always `FileId::Rev`, not `FileId::Wdir`, but we store
+    /// `FileId` because everything would have to convert to it anyways.
     parents: Option<Vec<FileId>>,
     /// Current state for annotating the file.
     file: AnnotatedFileState,
@@ -283,7 +337,7 @@
     revision: ChangelogRevisionState,
     /// The value of `revision` from a descendant. If the linkrev needs
     /// adjustment, we can start iterating the changelog here.
-    descendant: Option<Revision>,
+    descendant: Option<RevisionOrWdir>,
 }
 
 /// State enum for reading a file and annotating it.
@@ -302,7 +356,7 @@
     #[default]
     NotNeeded,
     Needed,
-    Done(Revision),
+    Done(RevisionOrWdir),
 }
 
 /// A collection of [`FileInfo`], forming a graph via [`FileInfo::parents`].
@@ -332,7 +386,7 @@
 pub fn annotate(
     repo: &Repo,
     path: &HgPath,
-    changelog_revision: Revision,
+    changelog_revision: RevisionOrWdir,
     options: AnnotateOptions,
 ) -> Result<AnnotateOutput, HgError> {
     // Step 1: Load the base file and check if it's binary.
@@ -358,7 +412,7 @@
             continue;
         }
         let (parents, file_data) =
-            fls.parents(&state, id, options.follow_copies)?;
+            fls.parents(&state, path, id, options.follow_copies)?;
         info.parents = Some(parents.clone());
         if let Some(data) = file_data {
             info.file = AnnotatedFileState::Read(OwnedLines::split(
@@ -391,6 +445,9 @@
     graph.0.par_iter_mut().try_for_each(
         |(&id, info)| -> Result<(), HgError> {
             if let AnnotatedFileState::None = info.file {
+                let FileId::Rev(id) = id else {
+                    unreachable!("only the base file can be wdir");
+                };
                 info.file = AnnotatedFileState::Read(OwnedLines::split(
                     fls.read(id)?,
                     options.whitespace,
@@ -500,7 +557,10 @@
     let mut changeset_annotations = Vec::with_capacity(annotations.len());
     for Annotation { id, line_number } in annotations {
         changeset_annotations.push(ChangesetAnnotation {
-            path: fls.get(id.index).path.clone(),
+            path: match id {
+                FileId::Wdir => path.into(),
+                FileId::Rev(id) => fls.get(id.index).path.clone(),
+            },
             revision: match graph[id].revision {
                 ChangelogRevisionState::Done(revision) => revision,
                 _ => unreachable!(),
@@ -543,13 +603,18 @@
 /// stopping at `stop_revision` if provided. Panics if `base_revision` is null.
 fn ancestor_iter<'a>(
     state: &'a RepoState<'a>,
-    base_revision: Revision,
+    base_revision: RevisionOrWdir,
     stop_revision: Option<Revision>,
 ) -> AncestorsIterator<&'a Changelog> {
+    let base_revisions: &[Revision] = match base_revision.exclude_wdir() {
+        Some(rev) => &[rev],
+        None => &state.dirstate_parents(),
+    };
+    let stop_revision = stop_revision.unwrap_or(NULL_REVISION);
     AncestorsIterator::new(
         &*state.changelog,
-        [base_revision],
-        stop_revision.unwrap_or(NULL_REVISION),
+        base_revisions.iter().copied(),
+        stop_revision,
         true,
     )
     .expect("base_revision should not be null")
@@ -561,15 +626,18 @@
     state: &RepoState<'_>,
     fls: &FilelogSet,
     ancestors: &mut AncestorsIterator<&Changelog>,
-    descendant: Revision,
+    descendant: RevisionOrWdir,
     id: FileId,
-) -> Result<Revision, HgError> {
+) -> Result<RevisionOrWdir, HgError> {
+    let FileId::Rev(id) = id else {
+        return Ok(RevisionOrWdir::wdir());
+    };
     let FilelogSetItem { filelog, path } = fls.get(id.index);
     let linkrev = filelog
         .revlog
         .link_revision(id.revision, &state.changelog.revlog)?;
     if ancestors.contains(linkrev).map_err(from_graph_error)? {
-        return Ok(linkrev);
+        return Ok(linkrev.into());
     }
     let file_node = *filelog.revlog.node_from_rev(id.revision);
     for ancestor in ancestor_iter(state, descendant, Some(linkrev)) {
@@ -586,17 +654,27 @@
                 .find_by_path(path)?
             {
                 if entry.node_id()? == file_node {
-                    return Ok(ancestor);
+                    return Ok(ancestor.into());
                 }
             }
         }
     }
     // In theory this should be unreachable. But in case it happens, return the
     // linkrev. This matches _adjustlinkrev in context.py.
-    Ok(linkrev)
+    Ok(linkrev.into())
 }
 
 /// Converts a [`GraphError`] to an [`HgError`].
 fn from_graph_error(err: GraphError) -> HgError {
     HgError::corrupted(err.to_string())
 }
+
+/// Converts a [`DirstateError`] to an [`HgError`].
+fn from_dirstate_error(err: DirstateError) -> HgError {
+    match err {
+        DirstateError::Map(err) => {
+            HgError::abort_simple(format!("dirstate error: {err}"))
+        }
+        DirstateError::Common(err) => err,
+    }
+}
diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -1,16 +1,17 @@
 use core::str;
 use std::{cell::Ref, collections::hash_map::Entry, ffi::OsString};
 
-use chrono::{DateTime, FixedOffset};
+use chrono::{DateTime, FixedOffset, Local};
 use format_bytes::format_bytes;
 use hg::{
     encoding::Encoder,
+    errors::IoResultExt as _,
     operations::{
         annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation,
     },
     repo::Repo,
-    revlog::changelog::Changelog,
-    utils::strings::CleanWhitespace,
+    revlog::{changelog::Changelog, RevisionOrWdir},
+    utils::{hg_path::HgPath, strings::CleanWhitespace},
     FastHashMap, Node, Revision,
 };
 
@@ -153,11 +154,6 @@
 
     let rev = args.get_one::<String>("rev").expect("rev has a default");
     let rev = hg::revset::resolve_single(rev, repo)?;
-    let Some(rev) = rev.exclude_wdir() else {
-        return Err(CommandError::unsupported(
-            "annotate wdir not implemented",
-        ));
-    };
 
     let files = match args.get_many::<OsString>("files") {
         None => vec![],
@@ -204,17 +200,27 @@
         (true, true) => unreachable!(),
     };
 
-    let changelog = repo.changelog()?;
+    let wdir_config = if rev.is_wdir() {
+        let user = config.username()?;
+        Some(WdirConfig { user })
+    } else {
+        None
+    };
+
     let mut formatter = Formatter::new(
         repo,
         invocation.ui.encoder(),
-        FormatterConfig { include, verbosity },
+        FormatterConfig {
+            include,
+            verbosity,
+            wdir_config,
+        },
     )?;
     let mut stdout = invocation.ui.stdout_buffer();
     for path in files {
         match annotate(repo, &path, rev, options)? {
             AnnotateOutput::Text(text) => {
-                let annotations = formatter.format(text.annotations)?;
+                let annotations = formatter.format(&path, text.annotations)?;
                 for (annotation, line) in annotations.iter().zip(&text.lines) {
                     stdout.write_all(&format_bytes!(
                         b"{}: {}", annotation, line
@@ -233,10 +239,16 @@
                 ))?;
             }
             AnnotateOutput::NotFound => {
-                let short = changelog.node_from_rev(rev).short();
-                return Err(CommandError::abort(format!(
-                    "abort: {path}: no such file in rev {short:x}",
-                )));
+                return Err(CommandError::abort(match rev.exclude_wdir() {
+                    Some(rev) => {
+                        let short =
+                            repo.changelog()?.node_from_rev(rev).short();
+                        format!("abort: {path}: no such file in rev {short:x}",)
+                    }
+                    None => {
+                        format!("abort: {path}: No such file or directory")
+                    }
+                }));
             }
         }
     }
@@ -246,15 +258,18 @@
 }
 
 struct Formatter<'a> {
+    repo: &'a Repo,
     changelog: Ref<'a, Changelog>,
+    dirstate_p1: Revision,
     encoder: &'a Encoder,
     config: FormatterConfig,
-    cache: FastHashMap<Revision, ChangesetData>,
+    cache: FastHashMap<RevisionOrWdir, ChangesetData>,
 }
 
 struct FormatterConfig {
     include: Include,
     verbosity: Verbosity,
+    wdir_config: Option<WdirConfig>,
 }
 
 struct Include {
@@ -284,6 +299,11 @@
     Verbose,
 }
 
+/// Information to use for lines that changed in the working directory.
+struct WdirConfig {
+    user: Vec<u8>,
+}
+
 #[derive(Default)]
 struct ChangesetData {
     user: Option<Vec<u8>>,
@@ -291,9 +311,18 @@
     date: Option<Vec<u8>>,
 }
 
+/// Whether the "+" sigil calculation is for --number or --changeset.
+#[derive(PartialEq, Eq)]
+enum SigilFor {
+    Number,
+    Changeset,
+}
+
 impl ChangesetData {
     fn create(
-        revision: Revision,
+        revision: RevisionOrWdir,
+        path: &HgPath,
+        repo: &Repo,
         changelog: &Changelog,
         config: &FormatterConfig,
     ) -> Result<Self, CommandError> {
@@ -301,10 +330,25 @@
         if !(include.user || include.changeset || include.date) {
             return Ok(Self::default());
         }
-        let entry = changelog.entry(revision)?;
-        let data = entry.data()?;
-        let node = *entry.as_revlog_entry().node();
-        Ok(Self::new(data.user(), node, data.timestamp()?, config))
+        match revision.exclude_wdir() {
+            Some(revision) => {
+                let entry = changelog.entry(revision)?;
+                let data = entry.data()?;
+                let node = *entry.as_revlog_entry().node();
+                Ok(Self::new(data.user(), node, data.timestamp()?, config))
+            }
+            None => {
+                let p1 = repo.dirstate_parents()?.p1;
+                let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?;
+                let meta =
+                    repo.working_directory_vfs().symlink_metadata(&fs_path)?;
+                let mtime = meta.modified().when_reading_file(&fs_path)?;
+                let mtime = DateTime::<Local>::from(mtime).fixed_offset();
+                let user =
+                    &config.wdir_config.as_ref().expect("should be set").user;
+                Ok(Self::new(user, p1, mtime, config))
+            }
+        }
     }
 
     fn new(
@@ -343,8 +387,12 @@
         config: FormatterConfig,
     ) -> Result<Self, CommandError> {
         let changelog = repo.changelog()?;
+        let dirstate_p1 =
+            changelog.rev_from_node(repo.dirstate_parents()?.p1.into())?;
         Ok(Self {
+            repo,
             changelog,
+            dirstate_p1,
             encoder,
             config,
             cache: FastHashMap::default(),
@@ -353,18 +401,24 @@
 
     fn format(
         &mut self,
+        path: &HgPath,
         annotations: Vec<ChangesetAnnotation>,
     ) -> Result<Vec<Vec<u8>>, CommandError> {
         let mut lines: Vec<Vec<Vec<u8>>> =
             Vec::with_capacity(annotations.len());
         let num_fields = self.config.include.count();
         let mut widths = vec![0usize; num_fields];
+        // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2`
+        // would use f1's mtime for lines in f2 attributed to wdir.
+        self.cache.remove(&RevisionOrWdir::wdir());
         for annotation in annotations {
-            let revision = annotation.revision;
-            let data = match self.cache.entry(revision) {
+            let rev = annotation.revision;
+            let data = match self.cache.entry(rev) {
                 Entry::Occupied(occupied) => occupied.into_mut(),
                 Entry::Vacant(vacant) => vacant.insert(ChangesetData::create(
-                    revision,
+                    rev,
+                    path,
+                    self.repo,
                     &self.changelog,
                     &self.config,
                 )?),
@@ -374,10 +428,13 @@
                 fields.push(user.clone());
             }
             if self.config.include.number {
-                fields.push(format_bytes!(b"{}", revision));
+                let number = rev.exclude_wdir().unwrap_or(self.dirstate_p1);
+                let sigil = fmt_sigil(&self.config, rev, SigilFor::Number);
+                fields.push(format_bytes!(b"{}{}", number, sigil));
             }
             if let Some(changeset) = &data.changeset {
-                fields.push(changeset.clone());
+                let sigil = fmt_sigil(&self.config, rev, SigilFor::Changeset);
+                fields.push(format_bytes!(b"{}{}", changeset, sigil));
             }
             if let Some(date) = &data.date {
                 fields.push(date.clone());
@@ -419,3 +476,23 @@
             .collect())
     }
 }
+
+fn fmt_sigil(
+    config: &FormatterConfig,
+    rev: RevisionOrWdir,
+    which: SigilFor,
+) -> &'static [u8] {
+    // The "+" sigil is only used for '--rev wdir()'.
+    if config.wdir_config.is_none() {
+        return b"";
+    };
+    // With --number --changeset, put it after the changeset.
+    if which == SigilFor::Number && config.include.changeset {
+        return b"";
+    }
+    if rev.is_wdir() {
+        b"+"
+    } else {
+        b" "
+    }
+}
diff --git a/tests/test-annotate.t b/tests/test-annotate.t
--- a/tests/test-annotate.t
+++ b/tests/test-annotate.t
@@ -693,8 +693,9 @@
   $ rm baz
 
   $ hg annotate -ncr "wdir()" baz
-  abort: $TESTTMP\repo/baz: $ENOENT$ (windows !)
-  abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !)
+  abort: baz: $ENOENT$ (rhg !)
+  abort: $TESTTMP\repo/baz: $ENOENT$ (no-rhg windows !)
+  abort: $ENOENT$: '$TESTTMP/repo/baz' (no-rhg no-windows !)
   [255]
 
 annotate removed file
@@ -702,8 +703,9 @@
   $ hg rm baz
 
   $ hg annotate -ncr "wdir()" baz
-  abort: $TESTTMP\repo/baz: $ENOENT$ (windows !)
-  abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !)
+  abort: baz: $ENOENT$ (rhg !)
+  abort: $TESTTMP\repo/baz: $ENOENT$ (no-rhg windows !)
+  abort: $ENOENT$: '$TESTTMP/repo/baz' (no-rhg no-windows !)
   [255]
 
 annotate file neither in repo nor working copy
diff --git a/tests/test-rhg.t b/tests/test-rhg.t
--- a/tests/test-rhg.t
+++ b/tests/test-rhg.t
@@ -202,6 +202,8 @@
   test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content
   $ $NO_FALLBACK rhg blame -r . -ufdnclawbBZ --no-follow original
   test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content
+  $ $NO_FALLBACK rhg annotate -r 'wdir()' original
+  0 : original content
 
 Fallback to Python
   $ $NO_FALLBACK rhg cat original --exclude="*.rs"
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1741288729 18000
#      Thu Mar 06 14:18:49 2025 -0500
# Node ID 9eb8ca3f9f33f590f2ea3504fe45599d8b327a44
# Parent  08ee687dbf3df3bd28db016a0d163d1d6e7d29a0
# EXP-Topic annotate-json
annotate: add test for non-UTF8 with -Tjson

This adds a test to test-annotate.t for annotating a non-UTF8 file with -Tjson.
The current behavior is incorrect and outputs invalid JSON. I plan to fix the
behavior in rhg annotate.

diff --git a/tests/test-annotate.t b/tests/test-annotate.t
--- a/tests/test-annotate.t
+++ b/tests/test-annotate.t
@@ -1060,6 +1060,19 @@
    }
   ]
 
+Test non-UTF8 (should use U+FFFD replacement character)
+TODO: fix Python which instead emits invalid JSON
+
+  $ "$PYTHON" -c 'open("latin1", "wb").write(b"\xc9")'
+  $ hg ci -qAm 'add latin1 file'
+  $ hg annotate -Tjson latin1
+  [
+   {
+    "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (known-bad-output !)
+    "path": "latin1"
+   }
+  ]
+
 Test annotate with whitespace options
 
   $ cd ..
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1741125338 18000
#      Tue Mar 04 16:55:38 2025 -0500
# Node ID 64cb5571db3131d1fc916c5b9169e1e0a9b83b52
# Parent  9eb8ca3f9f33f590f2ea3504fe45599d8b327a44
# EXP-Topic annotate-json
rhg: add dependency serde_json

This will be used for rhg annotate -Tjson.

diff --git a/rust/Cargo.lock b/rust/Cargo.lock
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -842,6 +842,12 @@
 ]
 
 [[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
 name = "jobserver"
 version = "0.1.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1359,6 +1365,7 @@
  "logging_timer",
  "rayon",
  "regex",
+ "serde_json",
  "shellexpand",
  "which",
  "whoami",
@@ -1387,6 +1394,12 @@
 ]
 
 [[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
 name = "same-file"
 version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1420,18 +1433,18 @@
 
 [[package]]
 name = "serde"
-version = "1.0.215"
+version = "1.0.218"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
+checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.215"
+version = "1.0.218"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
+checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1439,6 +1452,18 @@
 ]
 
 [[package]]
+name = "serde_json"
+version = "1.0.140"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
 name = "serde_spanned"
 version = "0.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/rust/rhg/Cargo.toml b/rust/rhg/Cargo.toml
--- a/rust/rhg/Cargo.toml
+++ b/rust/rhg/Cargo.toml
@@ -27,3 +27,4 @@
 which = "4.3.0"
 rayon = "1.7.0"
 libc = "0.2.155"
+serde_json = "1.0.140"
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1741276265 18000
#      Thu Mar 06 10:51:05 2025 -0500
# Node ID 142627c0471b816b7d2912990cb2bb3b14cf8177
# Parent  64cb5571db3131d1fc916c5b9169e1e0a9b83b52
# EXP-Topic annotate-json
rust-annotate: refactor in preparation for -Tjson

I had to restructure the annotation formatting to support multiple templates,
so I split this into a separate change.

diff --git a/rust/hg-core/src/operations/mod.rs b/rust/hg-core/src/operations/mod.rs
--- a/rust/hg-core/src/operations/mod.rs
+++ b/rust/hg-core/src/operations/mod.rs
@@ -8,7 +8,8 @@
 mod list_tracked_files;
 mod status_rev_rev;
 pub use annotate::{
-    annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation,
+    annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotatedFile,
+    ChangesetAnnotation,
 };
 pub use cat::{cat, CatOutput};
 pub use debugdata::debug_data;
diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -5,9 +5,9 @@
 use format_bytes::format_bytes;
 use hg::{
     encoding::Encoder,
-    errors::IoResultExt as _,
+    errors::{HgError, IoResultExt as _},
     operations::{
-        annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation,
+        annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotatedFile,
     },
     repo::Repo,
     revlog::{changelog::Changelog, RevisionOrWdir},
@@ -15,7 +15,10 @@
     FastHashMap, Node, Revision,
 };
 
-use crate::{error::CommandError, utils::path_utils::resolve_file_args};
+use crate::{
+    error::CommandError, ui::StdoutBuffer,
+    utils::path_utils::resolve_file_args,
+};
 
 pub const HELP_TEXT: &str = "
 show changeset information by line for each file
@@ -207,66 +210,20 @@
         None
     };
 
-    let mut formatter = Formatter::new(
-        repo,
-        invocation.ui.encoder(),
-        FormatterConfig {
-            include,
-            verbosity,
-            wdir_config,
-        },
-    )?;
-    let mut stdout = invocation.ui.stdout_buffer();
-    for path in files {
-        match annotate(repo, &path, rev, options)? {
-            AnnotateOutput::Text(text) => {
-                let annotations = formatter.format(&path, text.annotations)?;
-                for (annotation, line) in annotations.iter().zip(&text.lines) {
-                    stdout.write_all(&format_bytes!(
-                        b"{}: {}", annotation, line
-                    ))?;
-                }
-                if let Some(line) = text.lines.last() {
-                    if !line.ends_with(b"\n") {
-                        stdout.write_all(b"\n")?;
-                    }
-                }
-            }
-            AnnotateOutput::Binary => {
-                stdout.write_all(&format_bytes!(
-                    b"{}: binary file\n",
-                    path.as_bytes()
-                ))?;
-            }
-            AnnotateOutput::NotFound => {
-                return Err(CommandError::abort(match rev.exclude_wdir() {
-                    Some(rev) => {
-                        let short =
-                            repo.changelog()?.node_from_rev(rev).short();
-                        format!("abort: {path}: no such file in rev {short:x}",)
-                    }
-                    None => {
-                        format!("abort: {path}: No such file or directory")
-                    }
-                }));
-            }
-        }
-    }
-    stdout.flush()?;
+    let format_config = FormatConfig {
+        include,
+        verbosity,
+        wdir_config,
+    };
 
-    Ok(())
+    let file_results = files.iter().map(|path| -> FileResult {
+        (path.as_ref(), annotate(repo, path, rev, options))
+    });
+
+    print_output(repo, invocation.ui, &format_config, rev, file_results)
 }
 
-struct Formatter<'a> {
-    repo: &'a Repo,
-    changelog: Ref<'a, Changelog>,
-    dirstate_p1: Revision,
-    encoder: &'a Encoder,
-    config: FormatterConfig,
-    cache: FastHashMap<RevisionOrWdir, ChangesetData>,
-}
-
-struct FormatterConfig {
+struct FormatConfig {
     include: Include,
     verbosity: Verbosity,
     wdir_config: Option<WdirConfig>,
@@ -304,6 +261,7 @@
     user: Vec<u8>,
 }
 
+/// Information that we can cache per changeset.
 #[derive(Default)]
 struct ChangesetData {
     user: Option<Vec<u8>>,
@@ -318,167 +276,138 @@
     Changeset,
 }
 
-impl ChangesetData {
-    fn create(
-        revision: RevisionOrWdir,
-        path: &HgPath,
-        repo: &Repo,
-        changelog: &Changelog,
-        config: &FormatterConfig,
-    ) -> Result<Self, CommandError> {
-        let include = &config.include;
-        if !(include.user || include.changeset || include.date) {
-            return Ok(Self::default());
-        }
-        match revision.exclude_wdir() {
-            Some(revision) => {
-                let entry = changelog.entry(revision)?;
-                let data = entry.data()?;
-                let node = *entry.as_revlog_entry().node();
-                Ok(Self::new(data.user(), node, data.timestamp()?, config))
+type FileResult<'a> = (&'a HgPath, Result<AnnotateOutput, HgError>);
+
+fn print_output<'a>(
+    repo: &Repo,
+    ui: &crate::Ui,
+    config: &FormatConfig,
+    rev: RevisionOrWdir,
+    file_results: impl Iterator<Item = FileResult<'a>>,
+) -> Result<(), CommandError> {
+    let encoder = ui.encoder();
+    let stdout = &mut ui.stdout_buffer();
+    let dirstate_p1 = repo
+        .changelog()?
+        .rev_from_node(repo.dirstate_parents()?.p1.into())?;
+    let mut cache = Cache::new(repo)?;
+    for (path, output) in file_results {
+        match output? {
+            AnnotateOutput::Text(file) => {
+                print_lines_default(
+                    file,
+                    config,
+                    stdout,
+                    encoder,
+                    dirstate_p1,
+                    cache.for_path(path),
+                )?;
             }
-            None => {
-                let p1 = repo.dirstate_parents()?.p1;
-                let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?;
-                let meta =
-                    repo.working_directory_vfs().symlink_metadata(&fs_path)?;
-                let mtime = meta.modified().when_reading_file(&fs_path)?;
-                let mtime = DateTime::<Local>::from(mtime).fixed_offset();
-                let user =
-                    &config.wdir_config.as_ref().expect("should be set").user;
-                Ok(Self::new(user, p1, mtime, config))
+            AnnotateOutput::Binary => {
+                stdout.write_all(&format_bytes!(
+                    b"{}: binary file\n",
+                    path.as_bytes()
+                ))?;
+            }
+            AnnotateOutput::NotFound => {
+                return handle_not_found(repo, rev, path)
             }
         }
     }
-
-    fn new(
-        user: &[u8],
-        changeset: Node,
-        date: DateTime<FixedOffset>,
-        config: &FormatterConfig,
-    ) -> Self {
-        let mut result = ChangesetData::default();
-        if config.include.user {
-            let user = match config.verbosity {
-                Verbosity::Verbose => user,
-                _ => hg::utils::strings::short_user(user),
-            };
-            result.user = Some(user.to_vec());
-        }
-        if config.include.changeset {
-            result.changeset =
-                Some(format!("{:x}", changeset.short()).into_bytes());
-        }
-        if config.include.date {
-            let date = date.format(match config.verbosity {
-                Verbosity::Quiet => "%Y-%m-%d",
-                _ => "%a %b %d %H:%M:%S %Y %z",
-            });
-            result.date = Some(format!("{}", date).into_bytes());
-        }
-        result
-    }
+    stdout.flush()?;
+    Ok(())
 }
 
-impl<'a> Formatter<'a> {
-    fn new(
-        repo: &'a Repo,
-        encoder: &'a Encoder,
-        config: FormatterConfig,
-    ) -> Result<Self, CommandError> {
-        let changelog = repo.changelog()?;
-        let dirstate_p1 =
-            changelog.rev_from_node(repo.dirstate_parents()?.p1.into())?;
-        Ok(Self {
-            repo,
-            changelog,
-            dirstate_p1,
-            encoder,
-            config,
-            cache: FastHashMap::default(),
-        })
-    }
+type Stdout<'a> =
+    StdoutBuffer<'a, std::io::BufWriter<std::io::StdoutLock<'a>>>;
 
-    fn format(
-        &mut self,
-        path: &HgPath,
-        annotations: Vec<ChangesetAnnotation>,
-    ) -> Result<Vec<Vec<u8>>, CommandError> {
-        let mut lines: Vec<Vec<Vec<u8>>> =
-            Vec::with_capacity(annotations.len());
-        let num_fields = self.config.include.count();
-        let mut widths = vec![0usize; num_fields];
-        // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2`
-        // would use f1's mtime for lines in f2 attributed to wdir.
-        self.cache.remove(&RevisionOrWdir::wdir());
-        for annotation in annotations {
-            let rev = annotation.revision;
-            let data = match self.cache.entry(rev) {
-                Entry::Occupied(occupied) => occupied.into_mut(),
-                Entry::Vacant(vacant) => vacant.insert(ChangesetData::create(
-                    rev,
-                    path,
-                    self.repo,
-                    &self.changelog,
-                    &self.config,
-                )?),
-            };
-            let mut fields = Vec::with_capacity(num_fields);
-            if let Some(user) = &data.user {
-                fields.push(user.clone());
+fn print_lines_default(
+    file: ChangesetAnnotatedFile,
+    config: &FormatConfig,
+    stdout: &mut Stdout,
+    encoder: &Encoder,
+    dirstate_p1: Revision,
+    mut cache: CacheForPath,
+) -> Result<(), CommandError> {
+    // Serialize the annotation fields (revision, user, etc.) for each line
+    // and keep track of their maximum lengths so that we can align them.
+    let mut field_lists: Vec<Vec<Vec<u8>>> =
+        Vec::with_capacity(file.annotations.len());
+    let num_fields = config.include.count();
+    let mut widths = vec![0usize; num_fields];
+    for annotation in file.annotations {
+        let rev = annotation.revision;
+        let data = cache.get_data(rev, config)?;
+        let mut fields = Vec::with_capacity(num_fields);
+        if let Some(user) = &data.user {
+            fields.push(user.clone());
+        }
+        if config.include.number {
+            let number = rev.exclude_wdir().unwrap_or(dirstate_p1);
+            let sigil = fmt_sigil(config, rev, SigilFor::Number);
+            fields.push(format_bytes!(b"{}{}", number, sigil));
+        }
+        if let Some(changeset) = &data.changeset {
+            let sigil = fmt_sigil(config, rev, SigilFor::Changeset);
+            fields.push(format_bytes!(b"{}{}", changeset, sigil));
+        }
+        if let Some(date) = &data.date {
+            fields.push(date.clone());
+        }
+        if config.include.file {
+            fields.push(annotation.path.into_vec());
+        }
+        if config.include.line_number {
+            fields.push(format_bytes!(b"{}", annotation.line_number));
+        }
+        for (field, width) in fields.iter().zip(widths.iter_mut()) {
+            *width = std::cmp::max(*width, encoder.column_width_bytes(field));
+        }
+        field_lists.push(fields);
+    }
+    // Print each line of the file prefixed by aligned annotations.
+    let total_width = widths.iter().sum::<usize>() + num_fields - 1;
+    for (fields, line) in field_lists.iter().zip(file.lines.iter()) {
+        let mut annotation = Vec::with_capacity(total_width);
+        for (i, (field, width)) in fields.iter().zip(widths.iter()).enumerate()
+        {
+            if i > 0 {
+                let colon = config.include.line_number && i == num_fields - 1;
+                annotation.push(if colon { b':' } else { b' ' });
             }
-            if self.config.include.number {
-                let number = rev.exclude_wdir().unwrap_or(self.dirstate_p1);
-                let sigil = fmt_sigil(&self.config, rev, SigilFor::Number);
-                fields.push(format_bytes!(b"{}{}", number, sigil));
-            }
-            if let Some(changeset) = &data.changeset {
-                let sigil = fmt_sigil(&self.config, rev, SigilFor::Changeset);
-                fields.push(format_bytes!(b"{}{}", changeset, sigil));
-            }
-            if let Some(date) = &data.date {
-                fields.push(date.clone());
-            }
-            if self.config.include.file {
-                fields.push(annotation.path.into_vec());
-            }
-            if self.config.include.line_number {
-                fields.push(format_bytes!(b"{}", annotation.line_number));
-            }
-            for (field, width) in fields.iter().zip(widths.iter_mut()) {
-                *width = std::cmp::max(
-                    *width,
-                    self.encoder.column_width_bytes(field),
-                );
-            }
-            lines.push(fields);
+            let padding = width - encoder.column_width_bytes(field);
+            annotation.resize(annotation.len() + padding, b' ');
+            annotation.extend_from_slice(field);
         }
-        let total_width = widths.iter().sum::<usize>() + num_fields - 1;
-        Ok(lines
-            .iter()
-            .map(|fields| {
-                let mut bytes = Vec::with_capacity(total_width);
-                for (i, (field, width)) in
-                    fields.iter().zip(widths.iter()).enumerate()
-                {
-                    if i > 0 {
-                        let colon = self.config.include.line_number
-                            && i == num_fields - 1;
-                        bytes.push(if colon { b':' } else { b' ' });
-                    }
-                    let padding =
-                        width - self.encoder.column_width_bytes(field);
-                    bytes.resize(bytes.len() + padding, b' ');
-                    bytes.extend_from_slice(field);
-                }
-                bytes
-            })
-            .collect())
+        stdout.write_all(&format_bytes!(b"{}: {}", annotation, line))?;
     }
+    if let Some(line) = file.lines.last() {
+        if !line.ends_with(b"\n") {
+            stdout.write_all(b"\n")?;
+        }
+    }
+    Ok(())
 }
 
+fn handle_not_found(
+    repo: &Repo,
+    rev: RevisionOrWdir,
+    path: &HgPath,
+) -> Result<(), CommandError> {
+    Err(CommandError::abort(match rev.exclude_wdir() {
+        Some(rev) => {
+            let short = repo.changelog()?.node_from_rev(rev).short();
+            format!("abort: {path}: no such file in rev {short:x}",)
+        }
+        None => {
+            format!("abort: {path}: No such file or directory")
+        }
+    }))
+}
+
+/// Returns the sigil to put after the revision number or changeset.
 fn fmt_sigil(
-    config: &FormatterConfig,
+    config: &FormatConfig,
     rev: RevisionOrWdir,
     which: SigilFor,
 ) -> &'static [u8] {
@@ -496,3 +425,122 @@
         b" "
     }
 }
+
+/// A cache of [`ChangesetData`] for each changeset we've seen.
+struct Cache<'a> {
+    repo: &'a Repo,
+    changelog: Ref<'a, Changelog>,
+    map: FastHashMap<RevisionOrWdir, ChangesetData>,
+}
+
+impl<'a> Cache<'a> {
+    fn new(repo: &'a Repo) -> Result<Self, CommandError> {
+        Ok(Self {
+            repo,
+            changelog: repo.changelog()?,
+            map: Default::default(),
+        })
+    }
+
+    fn for_path(&mut self, path: &'a HgPath) -> CacheForPath<'_, 'a> {
+        CacheForPath { cache: self, path }
+    }
+}
+
+/// [`Cache`] scoped to annotating a particular file.
+struct CacheForPath<'a, 'b> {
+    cache: &'a mut Cache<'b>,
+    path: &'a HgPath,
+}
+
+impl CacheForPath<'_, '_> {
+    fn get_data(
+        &mut self,
+        rev: RevisionOrWdir,
+        config: &FormatConfig,
+    ) -> Result<&ChangesetData, CommandError> {
+        Ok(match self.cache.map.entry(rev) {
+            Entry::Occupied(occupied) => occupied.into_mut(),
+            Entry::Vacant(vacant) => vacant.insert(ChangesetData::create(
+                rev,
+                self.path,
+                self.cache.repo,
+                &self.cache.changelog,
+                config,
+            )?),
+        })
+    }
+}
+
+impl Drop for CacheForPath<'_, '_> {
+    fn drop(&mut self) {
+        // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2`
+        // would use f1's mtime for lines in f2 attributed to wdir.
+        self.cache.map.remove(&RevisionOrWdir::wdir());
+    }
+}
+
+impl ChangesetData {
+    fn create(
+        revision: RevisionOrWdir,
+        path: &HgPath,
+        repo: &Repo,
+        changelog: &Changelog,
+        config: &FormatConfig,
+    ) -> Result<Self, CommandError> {
+        let include = &config.include;
+        if !(include.user || include.changeset || include.date) {
+            return Ok(Self::default());
+        }
+        match revision.exclude_wdir() {
+            Some(revision) => {
+                let entry = changelog.entry(revision)?;
+                let data = entry.data()?;
+                let node = *entry.as_revlog_entry().node();
+                Self::new(data.user(), node, data.timestamp()?, config)
+            }
+            None => {
+                let p1 = repo.dirstate_parents()?.p1;
+                let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?;
+                let meta =
+                    repo.working_directory_vfs().symlink_metadata(&fs_path)?;
+                let mtime = meta.modified().when_reading_file(&fs_path)?;
+                let mtime = DateTime::<Local>::from(mtime).fixed_offset();
+                let user =
+                    &config.wdir_config.as_ref().expect("should be set").user;
+                Self::new(user, p1, mtime, config)
+            }
+        }
+    }
+
+    fn new(
+        user: &[u8],
+        changeset: Node,
+        date: DateTime<FixedOffset>,
+        config: &FormatConfig,
+    ) -> Result<Self, CommandError> {
+        let mut result = ChangesetData::default();
+        if config.include.user {
+            let user = match config.verbosity {
+                Verbosity::Verbose => user.to_vec(),
+                _ => hg::utils::strings::short_user(user).to_vec(),
+            };
+            result.user = Some(user.to_vec());
+        }
+        if config.include.changeset {
+            let hex = format!("{:x}", changeset.short());
+            result.changeset = Some(hex.into_bytes());
+        }
+        if config.include.date {
+            let date = format!(
+                "{}",
+                date.format(match config.verbosity {
+                    Verbosity::Quiet => "%Y-%m-%d",
+                    _ => "%a %b %d %H:%M:%S %Y %z",
+                })
+            );
+            result.date = Some(date.into_bytes());
+        }
+        Ok(result)
+    }
+}
# HG changeset patch
# User Mitchell Kember <mkember@janestreet.com>
# Date 1741037847 18000
#      Mon Mar 03 16:37:27 2025 -0500
# Node ID a5b3ba7d67688d5b951e56c83c00bcb0bfd593ca
# Parent  142627c0471b816b7d2912990cb2bb3b14cf8177
# EXP-Topic annotate-json
rust-annotate: support -Tjson

This adds support for the json template in rhg annotate. All other -T/--template
values continue to fallback to Python.

I matched the format of the Python output so all existing tests pass. This was
not that hard to do printing JSON manually. The only thing I use serde_json for
is to escape strings.

diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -12,7 +12,8 @@
     repo::Repo,
     revlog::{changelog::Changelog, RevisionOrWdir},
     utils::{hg_path::HgPath, strings::CleanWhitespace},
-    FastHashMap, Node, Revision,
+    FastHashMap, Node, Revision, WORKING_DIRECTORY_HEX,
+    WORKING_DIRECTORY_REVISION,
 };
 
 use crate::{
@@ -141,6 +142,12 @@
                 .long("ignore-space-at-eol")
                 .action(clap::ArgAction::SetTrue),
         )
+        .arg(
+            clap::Arg::new("template")
+                .help("display with template")
+                .short('T')
+                .long("template"),
+        )
         .about(HELP_TEXT)
 }
 
@@ -203,6 +210,14 @@
         (true, true) => unreachable!(),
     };
 
+    let template = match args.get_one::<String>("template") {
+        None => Template::Default,
+        Some(name) if name == "json" => Template::Json,
+        _ => {
+            return Err(CommandError::unsupported("only -Tjson is suppported"))
+        }
+    };
+
     let wdir_config = if rev.is_wdir() {
         let user = config.username()?;
         Some(WdirConfig { user })
@@ -211,6 +226,7 @@
     };
 
     let format_config = FormatConfig {
+        template,
         include,
         verbosity,
         wdir_config,
@@ -224,11 +240,17 @@
 }
 
 struct FormatConfig {
+    template: Template,
     include: Include,
     verbosity: Verbosity,
     wdir_config: Option<WdirConfig>,
 }
 
+enum Template {
+    Default,
+    Json,
+}
+
 struct Include {
     user: bool,
     number: bool,
@@ -262,6 +284,7 @@
 }
 
 /// Information that we can cache per changeset.
+/// For [`Template::Json`], the values are JSON encoded.
 #[derive(Default)]
 struct ChangesetData {
     user: Option<Vec<u8>>,
@@ -291,27 +314,58 @@
         .changelog()?
         .rev_from_node(repo.dirstate_parents()?.p1.into())?;
     let mut cache = Cache::new(repo)?;
-    for (path, output) in file_results {
-        match output? {
-            AnnotateOutput::Text(file) => {
-                print_lines_default(
-                    file,
-                    config,
-                    stdout,
-                    encoder,
-                    dirstate_p1,
-                    cache.for_path(path),
-                )?;
+    match config.template {
+        Template::Default => {
+            for (path, output) in file_results {
+                match output? {
+                    AnnotateOutput::Text(file) => {
+                        print_lines_default(
+                            file,
+                            config,
+                            stdout,
+                            encoder,
+                            cache.for_path(path),
+                            dirstate_p1,
+                        )?;
+                    }
+                    AnnotateOutput::Binary => {
+                        stdout.write_all(&format_bytes!(
+                            b"{}: binary file\n",
+                            path.as_bytes()
+                        ))?;
+                    }
+                    AnnotateOutput::NotFound => {
+                        return handle_not_found(repo, rev, path)
+                    }
+                }
             }
-            AnnotateOutput::Binary => {
-                stdout.write_all(&format_bytes!(
-                    b"{}: binary file\n",
-                    path.as_bytes()
-                ))?;
+        }
+        Template::Json => {
+            stdout.write_all(b"[")?;
+            let mut file_sep: &[u8] = b"\n";
+            for (path, output) in file_results {
+                stdout.write_all(file_sep)?;
+                file_sep = b",\n";
+                stdout.write_all(b" {\n")?;
+                match output? {
+                    AnnotateOutput::Text(file) => {
+                        print_lines_json(
+                            file,
+                            config,
+                            stdout,
+                            cache.for_path(path),
+                        )?;
+                    }
+                    AnnotateOutput::Binary => {}
+                    AnnotateOutput::NotFound => {
+                        return handle_not_found(repo, rev, path)
+                    }
+                }
+                let path = json_string(path.as_bytes())?;
+                stdout
+                    .write_all(&format_bytes!(b"  \"path\": {}\n }", path))?;
             }
-            AnnotateOutput::NotFound => {
-                return handle_not_found(repo, rev, path)
-            }
+            stdout.write_all(b"\n]\n")?;
         }
     }
     stdout.flush()?;
@@ -326,8 +380,8 @@
     config: &FormatConfig,
     stdout: &mut Stdout,
     encoder: &Encoder,
+    mut cache: CacheForPath,
     dirstate_p1: Revision,
-    mut cache: CacheForPath,
 ) -> Result<(), CommandError> {
     // Serialize the annotation fields (revision, user, etc.) for each line
     // and keep track of their maximum lengths so that we can align them.
@@ -389,6 +443,60 @@
     Ok(())
 }
 
+fn print_lines_json(
+    file: ChangesetAnnotatedFile,
+    config: &FormatConfig,
+    stdout: &mut Stdout,
+    mut cache: CacheForPath,
+) -> Result<(), CommandError> {
+    stdout.write_all(b"  \"lines\": [")?;
+    let mut line_sep: &[u8] = b"";
+    for (annotation, line) in file.annotations.iter().zip(file.lines.iter()) {
+        stdout.write_all(line_sep)?;
+        line_sep = b", ";
+
+        let mut property_sep: &[u8] = b"";
+        let mut property = |key: &[u8], value: &[u8]| {
+            let res = format_bytes!(b"{}\"{}\": {}", property_sep, key, value);
+            property_sep = b", ";
+            res
+        };
+
+        stdout.write_all(b"{")?;
+        let rev = annotation.revision;
+        let data = cache.get_data(rev, config)?;
+        if let Some(date_json) = &data.date {
+            stdout.write_all(&property(b"date", date_json))?;
+        }
+        stdout.write_all(&property(b"line", &json_string(line)?))?;
+        if config.include.line_number {
+            let lineno = annotation.line_number.to_string();
+            stdout.write_all(&property(b"lineno", lineno.as_bytes()))?;
+        }
+        if let Some(changeset_json) = &data.changeset {
+            stdout.write_all(&property(b"node", changeset_json))?;
+        }
+        if config.include.file {
+            let path = json_string(annotation.path.as_bytes())?;
+            stdout.write_all(&property(b"path", &path))?;
+        }
+        if config.include.number {
+            let number = match rev.exclude_wdir() {
+                Some(rev) => rev.0,
+                None => WORKING_DIRECTORY_REVISION.0,
+            };
+            stdout
+                .write_all(&property(b"rev", number.to_string().as_bytes()))?;
+        }
+        if let Some(user_json) = &data.user {
+            stdout.write_all(&property(b"user", user_json))?;
+        }
+        stdout.write_all(b"}")?;
+    }
+    stdout.write_all(b"],\n")?;
+    Ok(())
+}
+
 fn handle_not_found(
     repo: &Repo,
     rev: RevisionOrWdir,
@@ -500,7 +608,11 @@
                 Self::new(data.user(), node, data.timestamp()?, config)
             }
             None => {
-                let p1 = repo.dirstate_parents()?.p1;
+                let node = match config.template {
+                    Template::Default => repo.dirstate_parents()?.p1,
+                    Template::Json => Node::from_hex(WORKING_DIRECTORY_HEX)
+                        .expect("wdir hex should parse"),
+                };
                 let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?;
                 let meta =
                     repo.working_directory_vfs().symlink_metadata(&fs_path)?;
@@ -508,7 +620,7 @@
                 let mtime = DateTime::<Local>::from(mtime).fixed_offset();
                 let user =
                     &config.wdir_config.as_ref().expect("should be set").user;
-                Self::new(user, p1, mtime, config)
+                Self::new(user, node, mtime, config)
             }
         }
     }
@@ -521,26 +633,49 @@
     ) -> Result<Self, CommandError> {
         let mut result = ChangesetData::default();
         if config.include.user {
-            let user = match config.verbosity {
-                Verbosity::Verbose => user.to_vec(),
-                _ => hg::utils::strings::short_user(user).to_vec(),
+            let user = match config.template {
+                Template::Default => match config.verbosity {
+                    Verbosity::Verbose => user.to_vec(),
+                    _ => hg::utils::strings::short_user(user).to_vec(),
+                },
+                Template::Json => json_string(user)?,
             };
             result.user = Some(user.to_vec());
         }
         if config.include.changeset {
-            let hex = format!("{:x}", changeset.short());
+            let hex = match config.template {
+                Template::Default => format!("{:x}", changeset.short()),
+                Template::Json => format!("\"{:x}\"", changeset),
+            };
             result.changeset = Some(hex.into_bytes());
         }
         if config.include.date {
-            let date = format!(
-                "{}",
-                date.format(match config.verbosity {
-                    Verbosity::Quiet => "%Y-%m-%d",
-                    _ => "%a %b %d %H:%M:%S %Y %z",
-                })
-            );
+            let date = match config.template {
+                Template::Default => {
+                    format!(
+                        "{}",
+                        date.format(match config.verbosity {
+                            Verbosity::Quiet => "%Y-%m-%d",
+                            _ => "%a %b %d %H:%M:%S %Y %z",
+                        })
+                    )
+                }
+                Template::Json => format!(
+                    "[{}.0, {}]",
+                    date.timestamp(),
+                    date.offset().utc_minus_local(),
+                ),
+            };
             result.date = Some(date.into_bytes());
         }
         Ok(result)
     }
 }
+
+fn json_string(text: &[u8]) -> Result<Vec<u8>, CommandError> {
+    serde_json::to_vec(&String::from_utf8_lossy(text)).map_err(|err| {
+        CommandError::abort(format!(
+            "failed to serialize string to JSON: {err}"
+        ))
+    })
+}
diff --git a/tests/test-annotate.t b/tests/test-annotate.t
--- a/tests/test-annotate.t
+++ b/tests/test-annotate.t
@@ -1068,7 +1068,8 @@
   $ hg annotate -Tjson latin1
   [
    {
-    "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (known-bad-output !)
+    "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (no-rhg known-bad-output !)
+    "lines": [{"line": "\xef\xbf\xbd", "rev": 35}], (esc) (rhg !)
     "path": "latin1"
    }
   ]
diff --git a/tests/test-rhg.t b/tests/test-rhg.t
--- a/tests/test-rhg.t
+++ b/tests/test-rhg.t
@@ -204,6 +204,13 @@
   test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content
   $ $NO_FALLBACK rhg annotate -r 'wdir()' original
   0 : original content
+  $ $NO_FALLBACK rhg annotate -Tjson original
+  [
+   {
+    "lines": [{"line": "original content\n", "rev": 0}],
+    "path": "original"
+   }
+  ]
 
 Fallback to Python
   $ $NO_FALLBACK rhg cat original --exclude="*.rs"