Skip to main content

mur_common/config/
attribution.rs

1use super::*;
2
3/// MUR's signature on outgoing work, stored under `attribution:` in
4/// `~/.mur/config.yaml`.
5///
6/// Both surfaces are agent *behavior*, not runtime behavior: no Rust code in
7/// this repo authors a user's commit or opens their PR — an agent does it by
8/// shelling out to `git` and `gh`. So the only lever is the system prompt, and
9/// this config is its single source of truth. Putting the text in a skill body
10/// instead would fork it: skill markdown is loaded verbatim, with no template
11/// expansion, so a signature written there could never follow this file.
12///
13/// The two knobs default asymmetrically on purpose. A PR body is a message
14/// MUR is writing, and a credit line in it is ours to place. A commit trailer
15/// is written permanently into a repository's history — often someone else's —
16/// where it outlives the PR, shows up in `git log` and `git blame` forever, and
17/// cannot be edited away without a rewrite. Opt-in is the honest default for
18/// the second one.
19///
20/// There is no `enabled` flag, because with per-surface strings it would be a
21/// second switch that can disagree with the first ("enabled: true" plus an
22/// empty `pr` — which wins?). An empty or whitespace-only string IS the off
23/// switch, and it is off for exactly one surface.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(default)]
26pub struct AttributionConfig {
27    /// Appended to PR bodies the agent creates. Empty = no PR signature.
28    pub pr: Option<String>,
29    /// Appended as a trailer to commits the agent makes. `None`/empty = off,
30    /// which is the default; see the type docs for why this one is opt-in.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub commit: Option<String>,
33}
34
35/// The shipped PR credit line.
36pub const DEFAULT_PR_ATTRIBUTION: &str = "Generated with [MUR](https://app.mur.run/products/mur)";
37
38impl Default for AttributionConfig {
39    fn default() -> Self {
40        Self {
41            pr: Some(DEFAULT_PR_ATTRIBUTION.to_string()),
42            commit: None,
43        }
44    }
45}
46
47/// `Some(trimmed)` only when the value carries actual text — the shared
48/// "empty means off" rule for both surfaces.
49fn signature(v: &Option<String>) -> Option<&str> {
50    v.as_deref().map(str::trim).filter(|s| !s.is_empty())
51}
52
53impl AttributionConfig {
54    /// The PR credit line, or `None` when the user switched it off.
55    pub fn pr_signature(&self) -> Option<&str> {
56        signature(&self.pr)
57    }
58
59    /// The commit trailer, or `None` (the default) when off.
60    pub fn commit_signature(&self) -> Option<&str> {
61        signature(&self.commit)
62    }
63
64    /// The system-prompt rule, or `None` when both surfaces are off so a user
65    /// who wants no signature pays no tokens for one.
66    ///
67    /// The signature text is quoted verbatim rather than described, so the
68    /// model copies it instead of paraphrasing a credit line into something
69    /// that is not the configured one.
70    pub fn prompt_fragment(&self) -> Option<String> {
71        let (pr, commit) = (self.pr_signature(), self.commit_signature());
72        if pr.is_none() && commit.is_none() {
73            return None;
74        }
75        let mut s = String::from(
76            "\n\n## MUR attribution\n\
77             When you publish work on the user's behalf, sign it with the exact text below — \
78             copy it verbatim, do not reword it, and add it once.\n",
79        );
80        if let Some(pr) = pr {
81            s.push_str(&format!(
82                "- Opening a pull request (`gh pr create`, or any PR/MR body you author): \
83                 end the body with its own line reading:\n  {pr}\n"
84            ));
85        }
86        if let Some(commit) = commit {
87            s.push_str(&format!(
88                "- Making a commit (`git commit`): add this as a trailer in the last paragraph \
89                 of the message, after a blank line:\n  {commit}\n"
90            ));
91        }
92        s.push_str(
93            "Never add a signature the user did not configure, and never sign a surface that is \
94             not listed here.",
95        );
96        Some(s)
97    }
98}
99
100#[cfg(test)]
101mod attribution_tests {
102    use crate::config::{AttributionConfig, Config};
103
104    /// The shipped default: a PR gets a MUR credit line, a commit does not.
105    /// The asymmetry is the whole design — a PR body is ours to sign, a commit
106    /// trailer is written permanently into someone else's git history.
107    #[test]
108    fn pr_signature_ships_on_and_commit_trailer_ships_off() {
109        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
110        assert_eq!(
111            c.attribution.pr.as_deref(),
112            Some("Generated with [MUR](https://app.mur.run/products/mur)")
113        );
114        assert_eq!(c.attribution.commit, None);
115    }
116
117    /// Both surfaces are independently overridable, and an empty string is the
118    /// off switch — there is no separate `enabled` flag to disagree with.
119    #[test]
120    fn empty_string_is_the_off_switch_per_surface() {
121        let c: Config = serde_yaml_ng::from_str("attribution:\n  pr: \"\"\n").unwrap();
122        assert!(c.attribution.pr_signature().is_none());
123
124        let c: Config = serde_yaml_ng::from_str(
125            "attribution:\n  pr: \"   \"\n  commit: \"Co-Authored-By: MUR <noreply@mur.run>\"\n",
126        )
127        .unwrap();
128        assert!(
129            c.attribution.pr_signature().is_none(),
130            "whitespace-only counts as off, not as a signature of spaces"
131        );
132        assert_eq!(
133            c.attribution.commit_signature(),
134            Some("Co-Authored-By: MUR <noreply@mur.run>")
135        );
136    }
137
138    #[test]
139    fn a_custom_pr_signature_replaces_the_default() {
140        let c: Config = serde_yaml_ng::from_str("attribution:\n  pr: \"Made by ACME\"\n").unwrap();
141        assert_eq!(c.attribution.pr_signature(), Some("Made by ACME"));
142    }
143
144    /// New config files advertise the default PR signature, but do not write a
145    /// misleading `commit: null`: absence is the explicit opt-in default.
146    #[test]
147    fn serialization_ships_pr_attribution_but_omits_disabled_commit_trailer() {
148        let yaml = serde_yaml::to_string(&Config::default()).unwrap();
149        assert!(
150            yaml.contains(
151                "attribution:\n  pr: Generated with [MUR](https://app.mur.run/products/mur)"
152            ),
153            "{yaml}"
154        );
155        assert!(!yaml.contains("commit:"), "{yaml}");
156    }
157
158    /// Nothing to say → nothing injected. A user who blanks both surfaces must
159    /// not pay system-prompt tokens for an empty rule.
160    #[test]
161    fn prompt_fragment_is_none_when_both_surfaces_are_off() {
162        let off = AttributionConfig {
163            pr: Some(String::new()),
164            commit: None,
165        };
166        assert_eq!(off.prompt_fragment(), None);
167    }
168
169    /// The fragment names the surface it governs and quotes the text verbatim,
170    /// so the model has no room to paraphrase the credit line.
171    #[test]
172    fn prompt_fragment_quotes_each_enabled_surface_verbatim() {
173        let c = AttributionConfig::default();
174        let f = c.prompt_fragment().expect("pr is on by default");
175        assert!(f.contains("## MUR attribution"), "{f}");
176        assert!(
177            f.contains("Generated with [MUR](https://app.mur.run/products/mur)"),
178            "the exact signature must appear, not a description of it: {f}"
179        );
180        assert!(f.contains("gh pr create"), "{f}");
181        assert!(
182            !f.to_lowercase().contains("git commit"),
183            "commit trailer is off by default, so its instruction must be absent: {f}"
184        );
185
186        let both = AttributionConfig {
187            pr: Some("Generated with MUR".into()),
188            commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
189        };
190        let f = both.prompt_fragment().unwrap();
191        assert!(f.contains("git commit"), "{f}");
192        assert!(f.contains("Co-Authored-By: MUR <noreply@mur.run>"), "{f}");
193    }
194
195    /// Commit-only is a real configuration: a user may keep their PR bodies
196    /// clean and still credit MUR in the trailer.
197    #[test]
198    fn commit_only_configuration_injects_only_the_commit_rule() {
199        let c = AttributionConfig {
200            pr: Some(String::new()),
201            commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
202        };
203        let f = c.prompt_fragment().unwrap();
204        assert!(f.contains("git commit"), "{f}");
205        assert!(!f.contains("gh pr create"), "{f}");
206    }
207}