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 let rules = spec
118 .rules
119 .as_ref()
120 .ok_or(AgentConfigError::MissingSpecField {
121 id: "junie",
122 field: "rules",
123 })?;
124 let path = Self::rules_path(scope)?;
125 scope.ensure_contained(&path)?;
126 let mut report = InstallReport::default();
127 file_lock::with_lock(&path, || {
128 let host = fs_atomic::read_to_string_or_empty(&path)?;
129 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
130 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
131 if outcome.no_change {
132 report.already_installed = true;
133 } else if outcome.existed {
134 report.patched.push(outcome.path.clone());
135 } else {
136 report.created.push(outcome.path.clone());
137 }
138 if let Some(b) = outcome.backup {
139 report.backed_up.push(b);
140 }
141 Ok::<(), AgentConfigError>(())
142 })?;
143 Ok(report)
144 }
145
146 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
147 HookSpec::validate_tag(tag)?;
148 let path = Self::rules_path(scope)?;
149 scope.ensure_contained(&path)?;
150 let mut report = UninstallReport::default();
151 file_lock::with_lock(&path, || {
152 let host = fs_atomic::read_to_string_or_empty(&path)?;
153 let (stripped, removed) = md_block::remove(&host, tag);
154
155 if !removed {
156 report.not_installed = true;
157 return Ok(());
158 }
159
160 if stripped.trim().is_empty() {
161 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
162 report.restored.push(path.clone());
163 } else {
164 safe_fs::remove_file(scope, &path)?;
165 report.removed.push(path.clone());
166 }
167 } else {
168 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
169 report.patched.push(path.clone());
170 }
171 Ok::<(), AgentConfigError>(())
172 })?;
173 Ok(report)
174 }
175}
176
177impl McpSurface for JunieAgent {
178 fn id(&self) -> &'static str {
179 "junie"
180 }
181
182 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
183 &[ScopeKind::Global, ScopeKind::Local]
184 }
185
186 fn mcp_status(
187 &self,
188 scope: &Scope,
189 name: &str,
190 expected_owner: &str,
191 ) -> Result<StatusReport, AgentConfigError> {
192 McpSpec::validate_name(name)?;
193 let cfg = Self::mcp_path(scope)?;
194 let ledger = ownership::mcp_ledger_for(&cfg);
195 let presence = mcp_json_object::config_presence(&cfg, name)?;
196 let recorded = ownership::owner_of(&ledger, name)?;
197 Ok(StatusReport::for_mcp(
198 name,
199 cfg,
200 ledger,
201 presence,
202 expected_owner,
203 recorded,
204 ))
205 }
206
207 fn plan_install_mcp(
208 &self,
209 scope: &Scope,
210 spec: &McpSpec,
211 ) -> Result<InstallPlan, AgentConfigError> {
212 agent_planning::mcp_json_object_install(
213 McpSurface::id(self),
214 scope,
215 spec,
216 Self::mcp_path(scope),
217 )
218 }
219
220 fn plan_uninstall_mcp(
221 &self,
222 scope: &Scope,
223 name: &str,
224 owner_tag: &str,
225 ) -> Result<UninstallPlan, AgentConfigError> {
226 agent_planning::mcp_json_object_uninstall(
227 McpSurface::id(self),
228 scope,
229 name,
230 owner_tag,
231 Self::mcp_path(scope),
232 )
233 }
234
235 fn install_mcp(
236 &self,
237 scope: &Scope,
238 spec: &McpSpec,
239 ) -> Result<InstallReport, AgentConfigError> {
240 spec.validate()?;
241 let cfg = Self::mcp_path(scope)?;
242 spec.validate_local_secret_policy(scope)?;
243 scope.ensure_contained(&cfg)?;
244 let ledger = ownership::mcp_ledger_for(&cfg);
245 mcp_json_object::install(&cfg, &ledger, spec)
246 }
247
248 fn uninstall_mcp(
249 &self,
250 scope: &Scope,
251 name: &str,
252 owner_tag: &str,
253 ) -> Result<UninstallReport, AgentConfigError> {
254 McpSpec::validate_name(name)?;
255 HookSpec::validate_tag(owner_tag)?;
256 let cfg = Self::mcp_path(scope)?;
257 scope.ensure_contained(&cfg)?;
258 let ledger = ownership::mcp_ledger_for(&cfg);
259 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
260 }
261}
262
263impl JunieAgent {
264 fn inline_layout(
265 &self,
266 scope: &Scope,
267 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
268 Ok(instructions_dir::InlineLayout {
269 config_dir: Self::instruction_config_dir(scope)?,
270 host_file: Self::rules_path(scope)?,
271 })
272 }
273}
274
275impl InstructionSurface for JunieAgent {
276 fn id(&self) -> &'static str {
277 "junie"
278 }
279
280 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
281 &[ScopeKind::Local]
282 }
283
284 fn instruction_status(
285 &self,
286 scope: &Scope,
287 name: &str,
288 expected_owner: &str,
289 ) -> Result<StatusReport, AgentConfigError> {
290 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
291 }
292
293 fn plan_install_instruction(
294 &self,
295 scope: &Scope,
296 spec: &InstructionSpec,
297 ) -> Result<InstallPlan, AgentConfigError> {
298 instructions_dir::inline_plan_install(
299 InstructionSurface::id(self),
300 scope,
301 self.inline_layout(scope),
302 spec,
303 )
304 }
305
306 fn plan_uninstall_instruction(
307 &self,
308 scope: &Scope,
309 name: &str,
310 owner_tag: &str,
311 ) -> Result<UninstallPlan, AgentConfigError> {
312 instructions_dir::inline_plan_uninstall(
313 InstructionSurface::id(self),
314 scope,
315 self.inline_layout(scope),
316 name,
317 owner_tag,
318 )
319 }
320
321 fn install_instruction(
322 &self,
323 scope: &Scope,
324 spec: &InstructionSpec,
325 ) -> Result<InstallReport, AgentConfigError> {
326 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
327 }
328
329 fn uninstall_instruction(
330 &self,
331 scope: &Scope,
332 name: &str,
333 owner_tag: &str,
334 ) -> Result<UninstallReport, AgentConfigError> {
335 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use serde_json::{json, Value};
343 use tempfile::tempdir;
344
345 fn rules_spec(tag: &str, body: &str) -> HookSpec {
346 HookSpec::builder(tag)
347 .command_program("noop", [] as [&str; 0])
348 .rules(body)
349 .build()
350 }
351
352 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
353 McpSpec::builder(name)
354 .owner(owner)
355 .stdio("npx", ["-y", "@example/server"])
356 .build()
357 }
358
359 fn read_json(p: &Path) -> Value {
360 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
361 }
362
363 #[test]
364 fn install_writes_dot_junie_agents_md() {
365 let dir = tempdir().unwrap();
366 let agent = JunieAgent::new();
367 let scope = Scope::Local(dir.path().to_path_buf());
368 agent
369 .install(&scope, &rules_spec("alpha", "Use Junie."))
370 .unwrap();
371 let body = std::fs::read_to_string(dir.path().join(".junie/AGENTS.md")).unwrap();
372 assert!(body.contains("Use Junie."));
373 }
374
375 #[test]
376 fn global_prompt_scope_rejected() {
377 let agent = JunieAgent::new();
378 let err = agent
379 .install(&Scope::Global, &rules_spec("alpha", "x"))
380 .unwrap_err();
381 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
382 }
383
384 #[test]
385 fn install_uninstall_round_trip() {
386 let dir = tempdir().unwrap();
387 let agent = JunieAgent::new();
388 let scope = Scope::Local(dir.path().to_path_buf());
389 agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
390 agent.uninstall(&scope, "alpha").unwrap();
391 assert!(!dir.path().join(".junie/AGENTS.md").exists());
392 }
393
394 #[test]
395 fn install_mcp_writes_nested_mcp_json() {
396 let dir = tempdir().unwrap();
397 let agent = JunieAgent::new();
398 let scope = Scope::Local(dir.path().to_path_buf());
399 agent
400 .install_mcp(&scope, &mcp_spec("github", "myapp"))
401 .unwrap();
402 let v = read_json(&dir.path().join(".junie/mcp/mcp.json"));
403 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
404 }
405
406 #[test]
407 fn install_mcp_idempotent() {
408 let dir = tempdir().unwrap();
409 let agent = JunieAgent::new();
410 let scope = Scope::Local(dir.path().to_path_buf());
411 let s = mcp_spec("github", "myapp");
412 agent.install_mcp(&scope, &s).unwrap();
413 let r = agent.install_mcp(&scope, &s).unwrap();
414 assert!(r.already_installed);
415 }
416
417 #[test]
418 fn uninstall_mcp_other_owner_refused() {
419 let dir = tempdir().unwrap();
420 let agent = JunieAgent::new();
421 let scope = Scope::Local(dir.path().to_path_buf());
422 agent
423 .install_mcp(&scope, &mcp_spec("github", "appA"))
424 .unwrap();
425 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
426 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
427 }
428}