mur_common/config/
attribution.rs1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(default)]
26pub struct AttributionConfig {
27 pub pr: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
32 pub commit: Option<String>,
33}
34
35pub 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
47fn signature(v: &Option<String>) -> Option<&str> {
50 v.as_deref().map(str::trim).filter(|s| !s.is_empty())
51}
52
53impl AttributionConfig {
54 pub fn pr_signature(&self) -> Option<&str> {
56 signature(&self.pr)
57 }
58
59 pub fn commit_signature(&self) -> Option<&str> {
61 signature(&self.commit)
62 }
63
64 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 #[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 #[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 #[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 #[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 #[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 #[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}