1use std::path::{Path, PathBuf};
15
16use crate::agents::planning as agent_planning;
17use crate::error::AgentConfigError;
18use crate::integration::{
19 InstallReport, InstructionSurface, Integration, McpSurface, UninstallReport,
20};
21use crate::paths;
22use crate::plan::{InstallPlan, UninstallPlan};
23use crate::scope::{Scope, ScopeKind};
24use crate::spec::{HookSpec, InstructionSpec, McpSpec};
25use crate::status::StatusReport;
26use crate::util::{
27 file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
28};
29
30#[derive(Debug, Clone, Copy, Default)]
32pub struct JunieAgent {
33 _private: (),
34}
35
36impl JunieAgent {
37 pub const fn new() -> Self {
39 Self { _private: () }
40 }
41
42 fn require_local(scope: &Scope) -> Result<&Path, AgentConfigError> {
43 match scope {
44 Scope::Local(p) => Ok(p),
45 Scope::Global => Err(AgentConfigError::UnsupportedScope {
46 id: "junie",
47 scope: ScopeKind::Global,
48 }),
49 }
50 }
51
52 fn rules_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
53 Ok(Self::require_local(scope)?.join(".junie").join("AGENTS.md"))
54 }
55
56 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57 Ok(match scope {
58 Scope::Global => paths::home_dir()?
59 .join(".junie")
60 .join("mcp")
61 .join("mcp.json"),
62 Scope::Local(p) => p.join(".junie").join("mcp").join("mcp.json"),
63 })
64 }
65
66 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
69 Ok(Self::require_local(scope)?.join(".junie"))
70 }
71}
72
73impl Integration for JunieAgent {
74 fn id(&self) -> &'static str {
75 "junie"
76 }
77
78 fn display_name(&self) -> &'static str {
79 "JetBrains Junie"
80 }
81
82 fn supported_scopes(&self) -> &'static [ScopeKind] {
83 &[ScopeKind::Local]
84 }
85
86 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
87 HookSpec::validate_tag(tag)?;
88 let path = Self::rules_path(scope)?;
89 StatusReport::for_markdown_block_hook(tag, path)
90 }
91
92 fn plan_install(
93 &self,
94 scope: &Scope,
95 spec: &HookSpec,
96 ) -> Result<InstallPlan, AgentConfigError> {
97 agent_planning::markdown_install(
98 Integration::id(self),
99 scope,
100 spec,
101 Self::rules_path(scope),
102 true,
103 )
104 }
105
106 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
107 agent_planning::markdown_uninstall(
108 Integration::id(self),
109 scope,
110 tag,
111 Self::rules_path(scope),
112 )
113 }
114
115 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
116 HookSpec::validate_tag(&spec.tag)?;
117 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
118 let rules = spec
119 .rules
120 .as_ref()
121 .ok_or(AgentConfigError::MissingSpecField {
122 id: "junie",
123 field: "rules",
124 })?;
125 let path = Self::rules_path(scope)?;
126 scope.ensure_contained(&path)?;
127 let mut report = InstallReport::default();
128 file_lock::with_lock(&path, || {
129 let host = fs_atomic::read_to_string_or_empty(&path)?;
130 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
131 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
132 if outcome.no_change {
133 report.already_installed = true;
134 } else if outcome.existed {
135 report.patched.push(outcome.path.clone());
136 } else {
137 report.created.push(outcome.path.clone());
138 }
139 if let Some(b) = outcome.backup {
140 report.backed_up.push(b);
141 }
142 Ok::<(), AgentConfigError>(())
143 })?;
144 Ok(report)
145 }
146
147 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
148 HookSpec::validate_tag(tag)?;
149 let path = Self::rules_path(scope)?;
150 scope.ensure_contained(&path)?;
151 let mut report = UninstallReport::default();
152 file_lock::with_lock(&path, || {
153 let host = fs_atomic::read_to_string_or_empty(&path)?;
154 let (stripped, removed) = md_block::remove(&host, tag);
155
156 if !removed {
157 report.not_installed = true;
158 return Ok(());
159 }
160
161 if stripped.trim().is_empty() {
162 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
163 report.restored.push(path.clone());
164 } else {
165 safe_fs::remove_file(scope, &path)?;
166 report.removed.push(path.clone());
167 }
168 } else {
169 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
170 report.patched.push(path.clone());
171 }
172 Ok::<(), AgentConfigError>(())
173 })?;
174 Ok(report)
175 }
176}
177
178impl McpSurface for JunieAgent {
179 fn id(&self) -> &'static str {
180 "junie"
181 }
182
183 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
184 &[ScopeKind::Global, ScopeKind::Local]
185 }
186
187 fn mcp_status(
188 &self,
189 scope: &Scope,
190 name: &str,
191 expected_owner: &str,
192 ) -> Result<StatusReport, AgentConfigError> {
193 McpSpec::validate_name(name)?;
194 let cfg = Self::mcp_path(scope)?;
195 let ledger = ownership::mcp_ledger_for(&cfg);
196 let presence = mcp_json_object::config_presence(&cfg, name)?;
197 let recorded = ownership::owner_of(&ledger, name)?;
198 Ok(StatusReport::for_mcp(
199 name,
200 cfg,
201 ledger,
202 presence,
203 expected_owner,
204 recorded,
205 ))
206 }
207
208 fn plan_install_mcp(
209 &self,
210 scope: &Scope,
211 spec: &McpSpec,
212 ) -> Result<InstallPlan, AgentConfigError> {
213 agent_planning::mcp_json_object_install(
214 McpSurface::id(self),
215 scope,
216 spec,
217 Self::mcp_path(scope),
218 )
219 }
220
221 fn plan_uninstall_mcp(
222 &self,
223 scope: &Scope,
224 name: &str,
225 owner_tag: &str,
226 ) -> Result<UninstallPlan, AgentConfigError> {
227 agent_planning::mcp_json_object_uninstall(
228 McpSurface::id(self),
229 scope,
230 name,
231 owner_tag,
232 Self::mcp_path(scope),
233 )
234 }
235
236 fn install_mcp(
237 &self,
238 scope: &Scope,
239 spec: &McpSpec,
240 ) -> Result<InstallReport, AgentConfigError> {
241 spec.validate()?;
242 let cfg = Self::mcp_path(scope)?;
243 spec.validate_local_secret_policy(scope)?;
244 scope.ensure_contained(&cfg)?;
245 let ledger = ownership::mcp_ledger_for(&cfg);
246 mcp_json_object::install(&cfg, &ledger, spec)
247 }
248
249 fn uninstall_mcp(
250 &self,
251 scope: &Scope,
252 name: &str,
253 owner_tag: &str,
254 ) -> Result<UninstallReport, AgentConfigError> {
255 McpSpec::validate_name(name)?;
256 HookSpec::validate_tag(owner_tag)?;
257 let cfg = Self::mcp_path(scope)?;
258 scope.ensure_contained(&cfg)?;
259 let ledger = ownership::mcp_ledger_for(&cfg);
260 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
261 }
262}
263
264impl JunieAgent {
265 fn inline_layout(
266 &self,
267 scope: &Scope,
268 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
269 Ok(instructions_dir::InlineLayout {
270 config_dir: Self::instruction_config_dir(scope)?,
271 host_file: Self::rules_path(scope)?,
272 })
273 }
274}
275
276impl InstructionSurface for JunieAgent {
277 fn id(&self) -> &'static str {
278 "junie"
279 }
280
281 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
282 &[ScopeKind::Local]
283 }
284
285 fn instruction_status(
286 &self,
287 scope: &Scope,
288 name: &str,
289 expected_owner: &str,
290 ) -> Result<StatusReport, AgentConfigError> {
291 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
292 }
293
294 fn plan_install_instruction(
295 &self,
296 scope: &Scope,
297 spec: &InstructionSpec,
298 ) -> Result<InstallPlan, AgentConfigError> {
299 instructions_dir::inline_plan_install(
300 InstructionSurface::id(self),
301 scope,
302 self.inline_layout(scope),
303 spec,
304 )
305 }
306
307 fn plan_uninstall_instruction(
308 &self,
309 scope: &Scope,
310 name: &str,
311 owner_tag: &str,
312 ) -> Result<UninstallPlan, AgentConfigError> {
313 instructions_dir::inline_plan_uninstall(
314 InstructionSurface::id(self),
315 scope,
316 self.inline_layout(scope),
317 name,
318 owner_tag,
319 )
320 }
321
322 fn install_instruction(
323 &self,
324 scope: &Scope,
325 spec: &InstructionSpec,
326 ) -> Result<InstallReport, AgentConfigError> {
327 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
328 }
329
330 fn uninstall_instruction(
331 &self,
332 scope: &Scope,
333 name: &str,
334 owner_tag: &str,
335 ) -> Result<UninstallReport, AgentConfigError> {
336 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use serde_json::{json, Value};
344 use tempfile::tempdir;
345
346 fn rules_spec(tag: &str, body: &str) -> HookSpec {
347 HookSpec::builder(tag)
348 .command_program("noop", [] as [&str; 0])
349 .rules(body)
350 .build()
351 }
352
353 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
354 McpSpec::builder(name)
355 .owner(owner)
356 .stdio("npx", ["-y", "@example/server"])
357 .build()
358 }
359
360 fn read_json(p: &Path) -> Value {
361 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
362 }
363
364 #[test]
365 fn install_writes_dot_junie_agents_md() {
366 let dir = tempdir().unwrap();
367 let agent = JunieAgent::new();
368 let scope = Scope::Local(dir.path().to_path_buf());
369 agent
370 .install(&scope, &rules_spec("alpha", "Use Junie."))
371 .unwrap();
372 let body = std::fs::read_to_string(dir.path().join(".junie/AGENTS.md")).unwrap();
373 assert!(body.contains("Use Junie."));
374 }
375
376 #[test]
377 fn global_prompt_scope_rejected() {
378 let agent = JunieAgent::new();
379 let err = agent
380 .install(&Scope::Global, &rules_spec("alpha", "x"))
381 .unwrap_err();
382 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
383 }
384
385 #[test]
386 fn install_uninstall_round_trip() {
387 let dir = tempdir().unwrap();
388 let agent = JunieAgent::new();
389 let scope = Scope::Local(dir.path().to_path_buf());
390 agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
391 agent.uninstall(&scope, "alpha").unwrap();
392 assert!(!dir.path().join(".junie/AGENTS.md").exists());
393 }
394
395 #[test]
396 fn install_mcp_writes_nested_mcp_json() {
397 let dir = tempdir().unwrap();
398 let agent = JunieAgent::new();
399 let scope = Scope::Local(dir.path().to_path_buf());
400 agent
401 .install_mcp(&scope, &mcp_spec("github", "myapp"))
402 .unwrap();
403 let v = read_json(&dir.path().join(".junie/mcp/mcp.json"));
404 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
405 }
406
407 #[test]
408 fn install_mcp_idempotent() {
409 let dir = tempdir().unwrap();
410 let agent = JunieAgent::new();
411 let scope = Scope::Local(dir.path().to_path_buf());
412 let s = mcp_spec("github", "myapp");
413 agent.install_mcp(&scope, &s).unwrap();
414 let r = agent.install_mcp(&scope, &s).unwrap();
415 assert!(r.already_installed);
416 }
417
418 #[test]
419 fn uninstall_mcp_other_owner_refused() {
420 let dir = tempdir().unwrap();
421 let agent = JunieAgent::new();
422 let scope = Scope::Local(dir.path().to_path_buf());
423 agent
424 .install_mcp(&scope, &mcp_spec("github", "appA"))
425 .unwrap();
426 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
427 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
428 }
429}