Skip to main content

context_harness/
profile_script.rs

1//! Lua scripted profile runtime.
2//!
3//! Loads `.lua` profile scripts at startup, extracts their metadata (name,
4//! description, tools, arguments), and provides runtime resolution with a
5//! context bridge back into the Rust core (search, get, sources).
6//!
7//! # Architecture
8//!
9//! Profile scripts follow the same sandboxed Lua VM model as connectors and
10//! tools, reusing all host APIs from [`crate::lua_runtime`]. In addition,
11//! profiles receive a `context` table identical to tools:
12//!
13//! - `context.search(query, opts?)` — search the knowledge base
14//! - `context.get(id)` — retrieve a document by UUID
15//! - `context.sources()` — list connector status
16//!
17//! The profile-specific config from `ctx.toml` is passed as the second
18//! argument to `profile.resolve(args, config, context)`.
19//!
20//! # Script Interface
21//!
22//! Every profile script defines a global `profile` table. The historical
23//! `agent` global remains accepted for compatibility:
24//!
25//! ```lua
26//! profile = {
27//!     name = "my-profile",
28//!     description = "Helps with tasks",
29//!     tools = { "search", "get" },
30//!     arguments = {
31//!         { name = "topic", description = "Focus area", required = false },
32//!     },
33//! }
34//!
35//! function profile.resolve(args, config, context)
36//!     return {
37//!         system = "You are a helpful assistant.",
38//!         messages = {},
39//!     }
40//! end
41//! ```
42//!
43//! # Configuration
44//!
45//! ```toml
46//! [profiles.script.my_profile]
47//! path = "profiles/my-profile.lua"
48//! timeout = 30
49//! search_limit = 5
50//! ```
51//!
52//! See the profiles documentation for the full specification.
53
54use anyhow::{bail, Context, Result};
55use async_trait::async_trait;
56use mlua::prelude::*;
57use std::path::{Path, PathBuf};
58use std::sync::Arc;
59use std::time::{Duration, Instant};
60
61use crate::config::{Config, ScriptProfileConfig};
62use crate::get::get_document;
63use crate::lua_runtime::{json_value_to_lua, register_all_host_apis, toml_table_to_lua};
64use crate::profiles::{Profile, ProfileArgument, ProfilePrompt, PromptMessage};
65use crate::search::search_documents;
66use crate::sources::get_sources;
67use crate::traits::ToolContext;
68
69// ═══════════════════════════════════════════════════════════════════════
70// Types
71// ═══════════════════════════════════════════════════════════════════════
72
73/// Metadata extracted from a loaded Lua profile script.
74///
75/// Created at startup by [`load_profile_definitions`]. Contains everything
76/// needed to register the profile and resolve its prompt when called.
77#[derive(Debug, Clone)]
78pub struct ProfileDefinition {
79    /// Profile identifier (matches the config key in `[profiles.script.<name>]`).
80    pub name: String,
81    /// One-line description for profile discovery.
82    pub description: String,
83    /// Tools this profile uses.
84    pub tools: Vec<String>,
85    /// Arguments the profile accepts.
86    pub arguments: Vec<ProfileArgument>,
87    /// Path to the `.lua` script file.
88    pub script_path: PathBuf,
89    /// Raw Lua source code (cached to avoid re-reading on every call).
90    pub script_source: String,
91    /// Profile-specific config keys from `ctx.toml`.
92    pub config: toml::Table,
93    /// Maximum execution time in seconds.
94    pub timeout: u64,
95}
96
97// ═══════════════════════════════════════════════════════════════════════
98// Profile trait adapter
99// ═══════════════════════════════════════════════════════════════════════
100
101/// Adapter that wraps a Lua [`ProfileDefinition`] as a [`Profile`] trait object.
102///
103/// This allows Lua profiles to participate in unified profile dispatch
104/// alongside TOML and custom Rust profiles.
105pub struct LuaProfileAdapter {
106    /// The underlying Lua profile definition.
107    definition: ProfileDefinition,
108    /// Application config needed for the context bridge.
109    config: Arc<Config>,
110}
111
112impl LuaProfileAdapter {
113    /// Create a new adapter wrapping a Lua profile definition.
114    pub fn new(definition: ProfileDefinition, config: Arc<Config>) -> Self {
115        Self { definition, config }
116    }
117}
118
119#[async_trait]
120impl Profile for LuaProfileAdapter {
121    fn name(&self) -> &str {
122        &self.definition.name
123    }
124
125    fn description(&self) -> &str {
126        &self.definition.description
127    }
128
129    fn tools(&self) -> Vec<String> {
130        self.definition.tools.clone()
131    }
132
133    fn source(&self) -> &str {
134        "lua"
135    }
136
137    fn arguments(&self) -> Vec<ProfileArgument> {
138        self.definition.arguments.clone()
139    }
140
141    async fn resolve(&self, args: serde_json::Value, _ctx: &ToolContext) -> Result<ProfilePrompt> {
142        resolve_profile(&self.definition, args, &self.config).await
143    }
144}
145
146// ═══════════════════════════════════════════════════════════════════════
147// Loading
148// ═══════════════════════════════════════════════════════════════════════
149
150/// Load all profile scripts from config and extract their definitions.
151///
152/// For each `[profiles.script.<name>]` entry, reads the script file, creates
153/// a temporary Lua VM to extract the `profile` table metadata, and converts
154/// the argument declarations.
155///
156/// Called once at startup.
157pub fn load_profile_definitions(config: &Config) -> Result<Vec<ProfileDefinition>> {
158    let mut profiles = Vec::new();
159
160    for (name, profile_config) in &config.profiles.script {
161        let profile_def = load_single_profile(name, profile_config)
162            .with_context(|| format!("Failed to load profile script '{}'", name))?;
163        profiles.push(profile_def);
164    }
165
166    Ok(profiles)
167}
168
169/// Load a single profile script and extract its definition.
170pub fn load_single_profile(
171    name: &str,
172    profile_config: &ScriptProfileConfig,
173) -> Result<ProfileDefinition> {
174    let script_src = std::fs::read_to_string(&profile_config.path).with_context(|| {
175        format!(
176            "Failed to read profile script: {}",
177            profile_config.path.display()
178        )
179    })?;
180
181    // Create a temporary Lua VM just to extract metadata
182    let lua = Lua::new();
183    lua.load(&script_src)
184        .set_name(profile_config.path.to_string_lossy())
185        .exec()
186        .map_err(|e| {
187            anyhow::anyhow!(
188                "Failed to execute profile script {}: {}",
189                profile_config.path.display(),
190                e
191            )
192        })?;
193
194    let profile_table = get_profile_table(&lua)?;
195
196    let description: String = profile_table
197        .get::<String>("description")
198        .unwrap_or_else(|_| format!("Lua profile: {}", name));
199
200    // Extract tools list
201    let tools = extract_string_list(&profile_table, "tools")?;
202
203    // Extract arguments
204    let arguments = extract_arguments(&profile_table)?;
205
206    Ok(ProfileDefinition {
207        name: name.to_string(),
208        description,
209        tools,
210        arguments,
211        script_path: profile_config.path.clone(),
212        script_source: script_src,
213        config: profile_config.extra.clone(),
214        timeout: profile_config.timeout,
215    })
216}
217
218/// Return the canonical `profile` table, falling back to the legacy `agent`
219/// global so existing scripts continue to work unchanged.
220fn get_profile_table(lua: &Lua) -> Result<LuaTable> {
221    if let Ok(profile) = lua.globals().get::<LuaTable>("profile") {
222        return Ok(profile);
223    }
224
225    lua.globals().get::<LuaTable>("agent").map_err(|_| {
226        anyhow::anyhow!(
227            "Script must define a global 'profile' table (legacy 'agent' is also accepted)"
228        )
229    })
230}
231
232/// Extract a list of strings from a Lua table field.
233fn extract_string_list(table: &LuaTable, key: &str) -> Result<Vec<String>> {
234    let mut result = Vec::new();
235    if let Ok(list) = table.get::<LuaTable>(key) {
236        let len = list.raw_len();
237        for i in 1..=len {
238            if let Ok(s) = list.raw_get::<String>(i) {
239                result.push(s);
240            }
241        }
242    }
243    Ok(result)
244}
245
246/// Extract argument definitions from a Lua `profile.arguments` table.
247fn extract_arguments(table: &LuaTable) -> Result<Vec<ProfileArgument>> {
248    let mut result = Vec::new();
249    if let Ok(args_table) = table.get::<LuaTable>("arguments") {
250        let len = args_table.raw_len();
251        for i in 1..=len {
252            if let Ok(arg) = args_table.raw_get::<LuaTable>(i) {
253                let name: String = arg.get::<String>("name").map_err(|e| {
254                    anyhow::anyhow!("Argument at index {} missing 'name': {}", i, e)
255                })?;
256                let description: String = arg.get::<String>("description").unwrap_or_default();
257                let required: bool = arg.get::<bool>("required").unwrap_or(false);
258                result.push(ProfileArgument {
259                    name,
260                    description,
261                    required,
262                });
263            }
264        }
265    }
266    Ok(result)
267}
268
269// ═══════════════════════════════════════════════════════════════════════
270// Resolution
271// ═══════════════════════════════════════════════════════════════════════
272
273/// Resolve a profile script's prompt.
274///
275/// Spawns a blocking thread, creates a sandboxed Lua VM with all host APIs
276/// plus the context bridge, and calls `profile.resolve(args, config, context)`.
277pub async fn resolve_profile(
278    profile: &ProfileDefinition,
279    args: serde_json::Value,
280    app_config: &Config,
281) -> Result<ProfilePrompt> {
282    let profile = profile.clone();
283    let config = app_config.clone();
284
285    tokio::task::spawn_blocking(move || run_lua_profile(&profile, args, &config))
286        .await
287        .context("Lua profile task panicked")?
288}
289
290/// Run the Lua profile synchronously on a blocking thread.
291fn run_lua_profile(
292    profile: &ProfileDefinition,
293    args: serde_json::Value,
294    config: &Config,
295) -> Result<ProfilePrompt> {
296    let script_dir = profile
297        .script_path
298        .parent()
299        .unwrap_or(Path::new("."))
300        .to_path_buf();
301
302    let lua = Lua::new();
303
304    // Set up timeout via instruction hook
305    let timeout_secs = profile.timeout;
306    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
307    lua.set_hook(
308        mlua::HookTriggers::new().every_nth_instruction(10_000),
309        move |_lua, _debug| {
310            if Instant::now() > deadline {
311                Err(mlua::Error::RuntimeError(format!(
312                    "profile timed out after {} seconds",
313                    timeout_secs
314                )))
315            } else {
316                Ok(mlua::VmState::Continue)
317            }
318        },
319    );
320
321    // Register all shared host APIs
322    let log_name = format!("profile:{}", profile.name);
323    register_all_host_apis(&lua, &log_name, &script_dir)?;
324
325    // Register context bridge (search, get, sources)
326    register_profile_context_bridge(&lua, config)?;
327
328    // Load and execute the script
329    lua.load(&profile.script_source)
330        .set_name(profile.script_path.to_string_lossy())
331        .exec()
332        .map_err(|e| {
333            anyhow::anyhow!(
334                "Failed to execute profile script {}: {}",
335                profile.script_path.display(),
336                e
337            )
338        })?;
339
340    // Get profile.resolve function (or legacy agent.resolve).
341    let profile_table = get_profile_table(&lua)?;
342
343    let resolve_fn: LuaFunction = profile_table
344        .get::<LuaFunction>("resolve")
345        .map_err(|e| anyhow::anyhow!("profile.resolve function not defined: {}", e))?;
346
347    // Convert args to Lua
348    let args_lua = json_value_to_lua(&lua, &args)?;
349
350    // Convert profile config to Lua
351    let config_lua = toml_table_to_lua(&lua, &profile.config)?;
352
353    // Get the context table we already registered
354    let context: LuaTable = lua
355        .globals()
356        .get::<LuaTable>("context")
357        .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
358
359    // Call profile.resolve(args, config, context)
360    let result: LuaValue = resolve_fn
361        .call::<LuaValue>((args_lua, config_lua, context))
362        .map_err(|e| {
363            anyhow::anyhow!(
364                "profile.resolve() failed in '{}': {}",
365                profile.script_path.display(),
366                e
367            )
368        })?;
369
370    // Convert result to ProfilePrompt
371    lua_result_to_profile_prompt(result)
372}
373
374/// Convert the Lua `profile.resolve()` return value to a [`ProfilePrompt`].
375///
376/// Expected shape:
377/// ```lua
378/// {
379///     system = "You are...",
380///     messages = {
381///         { role = "assistant", content = "I'm ready..." },
382///     }
383/// }
384/// ```
385fn lua_result_to_profile_prompt(value: LuaValue) -> Result<ProfilePrompt> {
386    match value {
387        LuaValue::Table(table) => {
388            let system: String = table.get::<String>("system").map_err(|_| {
389                anyhow::anyhow!("profile.resolve() must return a table with 'system' field")
390            })?;
391
392            // Extract tools override (optional — a profile might narrow its own list)
393            let tools = if let Ok(tools_table) = table.get::<LuaTable>("tools") {
394                let mut result = Vec::new();
395                let len = tools_table.raw_len();
396                for i in 1..=len {
397                    if let Ok(s) = tools_table.raw_get::<String>(i) {
398                        result.push(s);
399                    }
400                }
401                result
402            } else {
403                vec![]
404            };
405
406            // Extract messages (optional)
407            let messages = if let Ok(msgs_table) = table.get::<LuaTable>("messages") {
408                let mut result = Vec::new();
409                let len = msgs_table.raw_len();
410                for i in 1..=len {
411                    if let Ok(msg) = msgs_table.raw_get::<LuaTable>(i) {
412                        let role: String = msg
413                            .get::<String>("role")
414                            .unwrap_or_else(|_| "assistant".to_string());
415                        let content: String = msg.get::<String>("content").unwrap_or_default();
416                        result.push(PromptMessage { role, content });
417                    }
418                }
419                result
420            } else {
421                vec![]
422            };
423
424            Ok(ProfilePrompt {
425                system,
426                tools,
427                messages,
428            })
429        }
430        _ => {
431            anyhow::bail!(
432                "profile.resolve() must return a table, got {:?}",
433                value.type_name()
434            );
435        }
436    }
437}
438
439// ═══════════════════════════════════════════════════════════════════════
440// Context Bridge
441// ═══════════════════════════════════════════════════════════════════════
442
443/// Register the `context` table in the Lua VM for profile scripts.
444///
445/// Provides `context.search`, `context.get`, and `context.sources`.
446/// Uses the same bridge pattern as tool scripts.
447fn register_profile_context_bridge(lua: &Lua, config: &Config) -> LuaResult<()> {
448    let ctx = lua.create_table()?;
449
450    // context.search(query, opts?) → results
451    let cfg = config.clone();
452    ctx.set(
453        "search",
454        lua.create_function(move |lua, (query, opts): (String, Option<LuaTable>)| {
455            let mode = opts
456                .as_ref()
457                .and_then(|o| o.get::<String>("mode").ok())
458                .unwrap_or_else(|| "keyword".to_string());
459            let limit = opts
460                .as_ref()
461                .and_then(|o| o.get::<i64>("limit").ok())
462                .unwrap_or(12);
463            let source = opts.as_ref().and_then(|o| o.get::<String>("source").ok());
464
465            let handle = tokio::runtime::Handle::current();
466            let results = handle
467                .block_on(async {
468                    search_documents(
469                        &cfg,
470                        &query,
471                        &mode,
472                        source.as_deref(),
473                        None,
474                        Some(limit),
475                        false,
476                    )
477                    .await
478                })
479                .map_err(mlua::Error::external)?;
480
481            search_results_to_lua(lua, &results)
482        })?,
483    )?;
484
485    // context.get(id) → document
486    let cfg = config.clone();
487    ctx.set(
488        "get",
489        lua.create_function(move |lua, id: String| {
490            let handle = tokio::runtime::Handle::current();
491            let doc = handle
492                .block_on(async { get_document(&cfg, &id).await })
493                .map_err(mlua::Error::external)?;
494
495            doc_response_to_lua(lua, &doc)
496        })?,
497    )?;
498
499    // context.sources() → sources
500    let cfg = config.clone();
501    ctx.set(
502        "sources",
503        lua.create_function(move |lua, ()| {
504            let sources = get_sources(&cfg);
505            sources_to_lua(lua, &sources)
506        })?,
507    )?;
508
509    lua.globals().set("context", ctx)?;
510    Ok(())
511}
512
513/// Convert search results to a Lua array table.
514fn search_results_to_lua(
515    lua: &Lua,
516    results: &[crate::search::SearchResultItem],
517) -> LuaResult<LuaTable> {
518    let table = lua.create_table()?;
519    for (i, item) in results.iter().enumerate() {
520        let row = lua.create_table()?;
521        row.set("id", item.id.as_str())?;
522        row.set("score", item.score)?;
523        row.set("source", item.source.as_str())?;
524        row.set("source_id", item.source_id.as_str())?;
525        row.set("updated_at", item.updated_at.as_str())?;
526        row.set("snippet", item.snippet.as_str())?;
527        if let Some(ref title) = item.title {
528            row.set("title", title.as_str())?;
529        }
530        if let Some(ref url) = item.source_url {
531            row.set("source_url", url.as_str())?;
532        }
533        table.set(i as i64 + 1, row)?;
534    }
535    Ok(table)
536}
537
538/// Convert a document response to a Lua table.
539fn doc_response_to_lua(lua: &Lua, doc: &crate::get::DocumentResponse) -> LuaResult<LuaTable> {
540    let table = lua.create_table()?;
541    table.set("id", doc.id.as_str())?;
542    table.set("source", doc.source.as_str())?;
543    table.set("source_id", doc.source_id.as_str())?;
544    table.set("content_type", doc.content_type.as_str())?;
545    table.set("body", doc.body.as_str())?;
546    table.set("created_at", doc.created_at.as_str())?;
547    table.set("updated_at", doc.updated_at.as_str())?;
548    if let Some(ref title) = doc.title {
549        table.set("title", title.as_str())?;
550    }
551    if let Some(ref author) = doc.author {
552        table.set("author", author.as_str())?;
553    }
554    if let Some(ref url) = doc.source_url {
555        table.set("source_url", url.as_str())?;
556    }
557
558    let chunks_table = lua.create_table()?;
559    for (i, chunk) in doc.chunks.iter().enumerate() {
560        let c = lua.create_table()?;
561        c.set("index", chunk.index)?;
562        c.set("text", chunk.text.as_str())?;
563        chunks_table.set(i as i64 + 1, c)?;
564    }
565    table.set("chunks", chunks_table)?;
566
567    Ok(table)
568}
569
570/// Convert source statuses to a Lua array table.
571fn sources_to_lua(lua: &Lua, sources: &[crate::sources::SourceStatus]) -> LuaResult<LuaTable> {
572    let table = lua.create_table()?;
573    for (i, s) in sources.iter().enumerate() {
574        let row = lua.create_table()?;
575        row.set("name", s.name.as_str())?;
576        row.set("configured", s.configured)?;
577        row.set("healthy", s.healthy)?;
578        if let Some(ref notes) = s.notes {
579            row.set("notes", notes.as_str())?;
580        }
581        table.set(i as i64 + 1, row)?;
582    }
583    Ok(table)
584}
585
586// ═══════════════════════════════════════════════════════════════════════
587// CLI: scaffold & test
588// ═══════════════════════════════════════════════════════════════════════
589
590/// Scaffold a new profile script from a template.
591///
592/// Creates `profiles/<name>.lua` with a commented template showing the
593/// profile interface and available host APIs.
594pub fn scaffold_profile(name: &str) -> Result<()> {
595    let dir = Path::new("profiles");
596    std::fs::create_dir_all(dir)?;
597
598    let filename = format!("{}.lua", name.replace('_', "-"));
599    let path = dir.join(&filename);
600
601    if path.exists() {
602        bail!("Profile script already exists: {}", path.display());
603    }
604
605    let template = format!(
606        r#"--[[
607  Context Harness Profile: {name}
608
609  Configuration (add to ctx.toml):
610
611    [profiles.script.{name}]
612    path = "profiles/{filename}"
613    timeout = 30
614    # search_limit = 5
615
616  Test:
617    ctx profile test {name} --arg key=value
618]]
619
620profile = {{
621    name = "{name}",
622    description = "TODO: describe what this profile does",
623    tools = {{ "search", "get" }},
624    arguments = {{
625        {{
626            name = "topic",
627            description = "Focus area for this session",
628            required = false,
629        }},
630    }},
631}}
632
633--- Resolve the profile's prompt for a conversation.
634--- @param args table User-provided argument values
635--- @param config table Profile-specific config from ctx.toml
636--- @param context table Bridge to Context Harness (search, get, sources)
637--- @return table Resolved prompt with system, tools, and messages
638function profile.resolve(args, config, context)
639    local topic = args.topic or "general"
640
641    -- Example: pre-search for relevant context
642    -- local results = context.search(topic, {{ mode = "keyword", limit = 5 }})
643    -- local context_text = ""
644    -- for _, r in ipairs(results) do
645    --     local doc = context.get(r.id)
646    --     context_text = context_text .. "\n---\n" .. doc.body
647    -- end
648
649    return {{
650        system = string.format([[
651You are a helpful assistant focused on %s.
652
653Use the search tool to find relevant documentation and ground
654your responses in the indexed knowledge base.
655        ]], topic),
656        messages = {{
657            {{
658                role = "assistant",
659                content = string.format(
660                    "I'm ready to help with %s. What would you like to know?",
661                    topic
662                ),
663            }},
664        }},
665    }}
666end
667
668return profile
669"#,
670        name = name,
671        filename = filename,
672    );
673
674    std::fs::write(&path, template)?;
675    println!("Created profile: {}", path.display());
676    println!();
677    println!("Add to your ctx.toml:");
678    println!();
679    println!("  [profiles.script.{}]", name);
680    println!("  path = \"profiles/{}\"", filename);
681    println!();
682    println!("Then test:");
683    println!();
684    println!("  ctx profile test {} --arg topic=\"deployment\"", name);
685
686    Ok(())
687}
688
689/// Test a profile script by resolving its prompt.
690///
691/// Loads the script, executes `profile.resolve()` with the provided arguments,
692/// and prints the resulting system prompt, tools, and messages.
693pub async fn test_profile(name: &str, args: Vec<(String, String)>, config: &Config) -> Result<()> {
694    let profile_config = config
695        .profiles
696        .script
697        .get(name)
698        .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found in config", name))?;
699
700    let profile_def = load_single_profile(name, profile_config)?;
701
702    println!("Profile: {}", profile_def.name);
703    println!("Source: lua ({})", profile_def.script_path.display());
704    println!(
705        "Tools: {}",
706        if profile_def.tools.is_empty() {
707            "(none defined)".to_string()
708        } else {
709            profile_def.tools.join(", ")
710        }
711    );
712    println!();
713
714    // Build args JSON
715    let mut args_json = serde_json::Map::new();
716    for (k, v) in &args {
717        let json_val = serde_json::from_str::<serde_json::Value>(v)
718            .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
719        args_json.insert(k.clone(), json_val);
720    }
721    let args_value = serde_json::Value::Object(args_json);
722
723    let start = Instant::now();
724    let prompt = resolve_profile(&profile_def, args_value, config).await?;
725    let elapsed = start.elapsed();
726
727    println!("System prompt ({} chars):", prompt.system.len());
728    for line in prompt.system.lines() {
729        println!("  {}", line);
730    }
731
732    if !prompt.tools.is_empty() {
733        println!();
734        println!("Tools override: {}", prompt.tools.join(", "));
735    }
736
737    if !prompt.messages.is_empty() {
738        println!();
739        println!("Messages ({}):", prompt.messages.len());
740        for msg in &prompt.messages {
741            println!("  [{}] {}", msg.role, msg.content);
742        }
743    }
744
745    println!();
746    println!("Resolved in {:.0?}", elapsed);
747
748    Ok(())
749}
750
751/// List all configured profiles and print their info.
752#[allow(dead_code)] // Retained for library callers; CLI uses the combined catalog.
753pub fn list_profiles(config: &Config) -> Result<()> {
754    let mut count = 0;
755
756    println!(
757        "{:<24} {:<8} {:<44} TOOLS",
758        "PROFILE", "TYPE", "DESCRIPTION"
759    );
760
761    // TOML profiles
762    for (name, cfg) in &config.profiles.inline {
763        println!(
764            "{:<24} {:<8} {:<44} {}",
765            name,
766            "toml",
767            truncate(&cfg.description, 44),
768            cfg.tools.join(", ")
769        );
770        count += 1;
771    }
772
773    // Lua profiles
774    let lua_defs = load_profile_definitions(config)?;
775    for def in &lua_defs {
776        println!(
777            "{:<24} {:<8} {:<44} {}",
778            def.name,
779            "lua",
780            truncate(&def.description, 44),
781            def.tools.join(", ")
782        );
783        count += 1;
784    }
785
786    if count == 0 {
787        println!("No profiles configured.");
788    }
789
790    Ok(())
791}
792
793/// Truncate a string to fit in a column.
794fn truncate(s: &str, max: usize) -> String {
795    if s.len() <= max {
796        s.to_string()
797    } else {
798        format!("{}...", &s[..max.saturating_sub(3)])
799    }
800}