Skip to main content

systemprompt_cli/commands/core/content/
edit.rs

1//! `core content edit` command with interactive selection.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use super::edit_apply::{
7    ContentEditState, apply_body_flags, apply_set_value_flags, apply_visibility_flags,
8};
9use super::types::UpdateOutput;
10use crate::cli_settings::CliConfig;
11use crate::context::CommandContext;
12use crate::interactive::{Prompter, resolve_required};
13use crate::shared::CommandOutput;
14use anyhow::{Result, anyhow};
15use clap::Args;
16use systemprompt_content::{CategoryIdUpdate, ContentRepository};
17use systemprompt_database::DbPool;
18use systemprompt_identifiers::{ContentId, LocaleCode, SourceId};
19use systemprompt_logging::CliService;
20
21#[derive(Debug, Args)]
22pub struct EditArgs {
23    #[arg(help = "Content ID or slug to edit")]
24    pub identifier: Option<String>,
25
26    #[arg(long, help = "Source ID (required when using slug)")]
27    pub source: Option<String>,
28
29    #[arg(long = "set", value_name = "KEY=VALUE", help = "Set a field value")]
30    pub set_values: Vec<String>,
31
32    #[arg(long, help = "Make content public", conflicts_with = "private")]
33    pub public: bool,
34
35    #[arg(long, help = "Make content private", conflicts_with = "public")]
36    pub private: bool,
37
38    #[arg(long, help = "Body content")]
39    pub body: Option<String>,
40
41    #[arg(long, help = "File containing body content")]
42    pub body_file: Option<String>,
43}
44
45pub(super) async fn execute(args: EditArgs, ctx: &CommandContext) -> Result<CommandOutput> {
46    let app = ctx.app_context().await?;
47    execute_with_pool(args, ctx.prompter(), app.db_pool(), &ctx.cli).await
48}
49
50pub async fn execute_with_pool(
51    args: EditArgs,
52    prompter: &dyn Prompter,
53    pool: &DbPool,
54    config: &CliConfig,
55) -> Result<CommandOutput> {
56    let repo = ContentRepository::new(pool)?;
57
58    let candidates = if args.identifier.is_none() && config.is_interactive() {
59        list_candidates(&repo, args.source.as_deref()).await?
60    } else {
61        Vec::new()
62    };
63    let identifier = resolve_required(args.identifier.clone(), "identifier", config, || {
64        prompt_content_selection(prompter, &candidates)
65    })?;
66
67    let content = if identifier.starts_with("content_")
68        || identifier.contains('-') && identifier.len() > 30
69    {
70        let id = ContentId::new(identifier.clone());
71        repo.get_by_id(&id)
72            .await?
73            .ok_or_else(|| anyhow!("Content not found: {}", identifier))?
74    } else {
75        let source_id = args
76            .source
77            .as_ref()
78            .ok_or_else(|| anyhow!("Source ID required when using slug"))?;
79        let source = SourceId::new(source_id.clone());
80        repo.get_by_source_and_slug(&source, &identifier, &LocaleCode::english())
81            .await?
82            .ok_or_else(|| anyhow!("Content not found: {} in source {}", identifier, source_id))?
83    };
84
85    let mut changes = Vec::new();
86    let mut state = ContentEditState {
87        title: content.title.clone(),
88        description: content.description.clone(),
89        body: content.body.clone(),
90        keywords: content.keywords.clone(),
91        image: content.image.clone(),
92        category_id: CategoryIdUpdate::Unchanged,
93        public_value: None,
94        kind_value: None,
95    };
96    apply_visibility_flags(&args, &mut state, &mut changes);
97    apply_body_flags(&args, &mut state, &mut changes)?;
98    apply_set_value_flags(&args, &mut state, &mut changes, &repo).await?;
99
100    if changes.is_empty() {
101        return Err(anyhow!(
102            "No changes specified. Use --set, --public, --private, --body, or --body-file"
103        ));
104    }
105
106    CliService::info(&format!("Updating content '{}'...", content.slug));
107
108    let params = systemprompt_content::UpdateContentParams::new(
109        content.id.clone(),
110        state.title,
111        state.description,
112        state.body,
113    )
114    .with_keywords(state.keywords)
115    .with_image(state.image)
116    .with_version_hash(content.version_hash.clone())
117    .with_category_id(state.category_id)
118    .with_public(state.public_value)
119    .with_kind(state.kind_value);
120
121    repo.update(&params).await?;
122
123    CliService::success(&format!("Content '{}' updated successfully", content.slug));
124
125    let output = UpdateOutput {
126        content_id: content.id,
127        slug: content.slug,
128        updated_fields: changes,
129        success: true,
130    };
131
132    Ok(CommandOutput::card_value("Content Updated", &output))
133}
134
135async fn list_candidates(
136    repo: &ContentRepository,
137    source: Option<&str>,
138) -> Result<Vec<systemprompt_content::Content>> {
139    let contents = if let Some(source) = source {
140        let source = SourceId::new(source.to_owned());
141        repo.list_by_source_limited(&source, &LocaleCode::english(), 50)
142            .await?
143    } else {
144        repo.list(50, 0).await?
145    };
146    Ok(contents)
147}
148
149fn prompt_content_selection(
150    prompter: &dyn Prompter,
151    contents: &[systemprompt_content::Content],
152) -> Result<String> {
153    if contents.is_empty() {
154        return Err(anyhow!("No content found"));
155    }
156
157    let items: Vec<String> = contents
158        .iter()
159        .map(|c| format!("{} - {} ({})", c.id.as_str(), c.title, c.source_id.as_str()))
160        .collect();
161
162    let selection = prompter.select("Select content to edit", &items)?;
163
164    Ok(contents[selection].id.as_str().to_owned())
165}