nika 0.35.4

Semantic YAML workflow engine for AI tasks - DAG execution, MCP integration, multi-provider LLM support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
//! Error path tests for INFER verb execution
//!
//! Tests the error handling in `TaskExecutor::run_infer()` for:
//! - Missing API key errors
//! - Template resolution failures
//! - Empty response handling
//!
//! Coverage target: `src/runtime/executor.rs` L460-517

use std::sync::Arc;

use nika::ast::{InferParams, TaskAction};
use nika::binding::ResolvedBindings;
use nika::error::NikaError;
use nika::event::EventLog;
use nika::runtime::TaskExecutor;
use nika::store::RunContext;
use pretty_assertions::assert_eq;
use serde_json::json;

// =============================================================================
// HELPER FUNCTIONS
// =============================================================================

/// Create a TaskExecutor with default settings for testing
fn create_executor() -> TaskExecutor {
    TaskExecutor::new("claude", None, None, EventLog::new())
}

/// Create a TaskExecutor with mock provider (for tests that don't need real API)
fn create_mock_executor() -> TaskExecutor {
    // Use "mock" as provider name - will trigger unknown provider error
    // but for template tests we can use any provider since error happens before API call
    TaskExecutor::new("claude", None, None, EventLog::new())
}

/// Create an InferParams from a prompt string
fn infer_params(prompt: &str) -> InferParams {
    InferParams {
        prompt: prompt.to_string(),
        ..Default::default()
    }
}

/// Guard that saves and restores environment variables
struct EnvGuard {
    vars: Vec<(String, Option<String>)>,
}

impl EnvGuard {
    /// Create a new guard that will save the specified env vars
    fn new(var_names: &[&str]) -> Self {
        let vars = var_names
            .iter()
            .map(|name| {
                let current = std::env::var(name).ok();
                (name.to_string(), current)
            })
            .collect();
        Self { vars }
    }

    /// Clear all the tracked env vars
    fn clear_all(&self) {
        for (name, _) in &self.vars {
            std::env::remove_var(name);
        }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        // Restore all env vars to their original values
        // Use catch_unwind to prevent panics during cleanup
        for (name, value) in &self.vars {
            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match value {
                Some(v) => std::env::set_var(name, v),
                None => std::env::remove_var(name),
            }));
        }
    }
}

// =============================================================================
// TEST 1: Missing API Key Error (rig-core panics, not errors)
// =============================================================================

/// Test that rig-core panics when ANTHROPIC_API_KEY is not set
///
/// NOTE: rig-core v0.31 panics in `anthropic::Client::from_env()` when the
/// env var is missing. This is a limitation of rig-core - it doesn't return
/// a Result, it panics. We test this with #[should_panic].
///
/// The test is marked #[ignore] because it interferes with parallel test
/// execution when API keys are set in the environment.
#[tokio::test]
#[ignore = "rig-core panics on missing API key - run separately without env vars"]
#[should_panic(expected = "ANTHROPIC_API_KEY not set")]
async fn test_infer_missing_api_key_panics() {
    // Guard saves current env vars and restores them on drop
    let guard = EnvGuard::new(&["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
    guard.clear_all();

    let executor = create_executor();
    let task_id: Arc<str> = "test_task".into();
    let action = TaskAction::Infer {
        infer: infer_params("Generate a headline"),
    };
    let bindings = ResolvedBindings::new();
    let datastore = RunContext::new();

    // This will panic in rig-core when creating the Claude client
    let _ = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;
}

/// Test that MissingApiKey error variant exists and has correct code
///
/// Since rig-core panics rather than returning an error, we test that
/// Nika's MissingApiKey error type is correctly defined for future use
/// (e.g., in ChatAgent which does pre-check env vars).
#[test]
fn test_missing_api_key_error_type() {
    let err = NikaError::MissingApiKey {
        provider: "claude".to_string(),
    };

    // Verify error code
    assert_eq!(err.code(), "NIKA-032");

    // Verify error message format
    let msg = err.to_string();
    assert!(msg.contains("NIKA-032"));
    assert!(msg.contains("claude"));
    assert!(msg.contains("Missing API key"));
}

// =============================================================================
// TEST 2: Template Resolution Failure
// =============================================================================

/// Test that infer fails when template references missing alias
///
/// When the prompt contains `{{with.missing}}` but 'missing' is not in bindings,
/// the template resolution should fail with a clear error.
#[tokio::test]
async fn test_infer_template_resolution_failure() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_template".into();

    // Prompt references {{with.context}} but we don't provide that binding
    let action = TaskAction::Infer {
        infer: infer_params("Generate based on: {{with.context}}"),
    };
    let bindings = ResolvedBindings::new(); // Empty - no 'context' binding
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    // Should fail with template error before even calling the provider
    assert!(result.is_err(), "Should fail with missing binding");

    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Verify it's a template/binding error
    assert!(
        err_str.contains("context") || err_str.contains("Alias"),
        "Error should mention missing alias 'context': {}",
        err_str
    );

    // Verify it's the TemplateError variant
    assert!(
        matches!(err, NikaError::TemplateError { .. }),
        "Expected NikaError::TemplateError, got: {:?}",
        err
    );
}

/// Test template failure with multiple missing aliases
#[tokio::test]
async fn test_infer_template_multiple_missing_aliases() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_multi".into();

    // Multiple missing aliases
    let action = TaskAction::Infer {
        infer: infer_params("Combine {{with.first}} with {{with.second}} and {{with.third}}"),
    };
    let bindings = ResolvedBindings::new();
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err_str = result.unwrap_err().to_string();

    // Should mention at least one of the missing aliases
    assert!(
        err_str.contains("first")
            || err_str.contains("second")
            || err_str.contains("third")
            || err_str.contains("Alias"),
        "Error should mention missing aliases: {}",
        err_str
    );
}

/// Test that nested path failure in template is handled
#[tokio::test]
async fn test_infer_template_nested_path_failure() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_nested".into();

    // Template references nested field that doesn't exist
    let action = TaskAction::Infer {
        infer: infer_params("Value: {{with.data.nonexistent.field}}"),
    };

    let mut bindings = ResolvedBindings::new();
    // 'data' exists but doesn't have 'nonexistent' field
    bindings.set("data", json!({"name": "test", "value": 42}));
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Should be a path/traversal error
    assert!(
        err_str.contains("nonexistent")
            || err_str.contains("not found")
            || err_str.contains("NIKA-052") // PathNotFound
            || err_str.contains("NIKA-073"), // InvalidTraversal
        "Error should indicate path not found: {}",
        err_str
    );
}

/// Test template with null value in binding (strict mode)
#[tokio::test]
async fn test_infer_template_null_value_error() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_null".into();

    let action = TaskAction::Infer {
        infer: infer_params("Result: {{with.result}}"),
    };

    let mut bindings = ResolvedBindings::new();
    // Binding exists but is null
    bindings.set("result", json!(null));
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Should be a null value error (NIKA-072)
    assert!(
        err_str.contains("NIKA-072") || err_str.contains("Null value") || err_str.contains("null"),
        "Error should indicate null value: {}",
        err_str
    );

    assert!(
        matches!(err, NikaError::NullValue { .. }),
        "Expected NikaError::NullValue, got: {:?}",
        err
    );
}

// =============================================================================
// TEST 3: Unknown Provider Error
// =============================================================================

/// Test that requesting an unknown provider fails with clear error
#[tokio::test]
async fn test_infer_unknown_provider() {
    let executor = create_executor();
    let task_id: Arc<str> = "test_unknown".into();

    // Specify an unknown provider
    let action = TaskAction::Infer {
        infer: InferParams {
            prompt: "Test prompt".to_string(),
            provider: Some("unknown_provider".to_string()),
            ..Default::default()
        },
    };
    let bindings = ResolvedBindings::new();
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Should indicate unknown provider
    assert!(
        err_str.contains("unknown_provider")
            || err_str.contains("Unknown")
            || err_str.contains("not configured"),
        "Error should mention unknown provider: {}",
        err_str
    );
}

// =============================================================================
// TEST 4: Template Resolution Success (Baseline)
// =============================================================================

/// Test that template resolution works correctly when bindings are present
/// (This is a baseline test to ensure our error tests are valid)
#[tokio::test]
async fn test_infer_template_resolution_success() {
    // Guard saves current env vars - we need API key for this test to reach provider
    let _guard = EnvGuard::new(&["ANTHROPIC_API_KEY"]);

    // Skip if no API key - we're testing template resolution, not provider
    if std::env::var("ANTHROPIC_API_KEY").is_err() {
        // Just verify template resolution works (we can't call the provider)
        // This test documents expected behavior when API key is available
        return;
    }

    let executor = create_executor();
    let task_id: Arc<str> = "test_success".into();

    let action = TaskAction::Infer {
        infer: infer_params("Generate headline for: {{with.product}}"),
    };

    let mut bindings = ResolvedBindings::new();
    bindings.set("product", json!("QR Code AI"));
    let datastore = RunContext::new();

    // This should succeed if API key is valid
    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    // If we have a valid API key, this should succeed
    // If the key is invalid/expired, we'll get an API error (not template error)
    match result {
        Ok(response) => {
            assert!(!response.is_empty(), "Response should not be empty");
        }
        Err(e) => {
            // Only acceptable errors are provider/API errors, not template errors
            let err_str = e.to_string();
            assert!(
                !err_str.contains("Template")
                    && !err_str.contains("Alias")
                    && !err_str.contains("NIKA-04"),
                "Template resolution should succeed, got template error: {}",
                err_str
            );
        }
    }
}

// =============================================================================
// TEST 5: Invalid Traversal Errors
// =============================================================================

/// Test traversing a string value (should fail)
#[tokio::test]
async fn test_infer_template_invalid_traversal_on_string() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_traverse_string".into();

    let action = TaskAction::Infer {
        infer: infer_params("Get field: {{with.name.field}}"),
    };

    let mut bindings = ResolvedBindings::new();
    // 'name' is a string, can't traverse into it
    bindings.set("name", json!("just a string"));
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Should be invalid traversal error (NIKA-073)
    assert!(
        err_str.contains("NIKA-073") || err_str.contains("string") || err_str.contains("traverse"),
        "Error should indicate invalid traversal on string: {}",
        err_str
    );

    assert!(
        matches!(err, NikaError::InvalidTraversal { .. }),
        "Expected NikaError::InvalidTraversal, got: {:?}",
        err
    );
}

/// Test traversing a number value (should fail)
#[tokio::test]
async fn test_infer_template_invalid_traversal_on_number() {
    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_traverse_number".into();

    let action = TaskAction::Infer {
        infer: infer_params("Get value: {{with.count.property}}"),
    };

    let mut bindings = ResolvedBindings::new();
    // 'count' is a number, can't traverse into it
    bindings.set("count", json!(42));
    let datastore = RunContext::new();

    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_str = err.to_string();

    // Should be invalid traversal error
    assert!(
        err_str.contains("NIKA-073") || err_str.contains("number"),
        "Error should indicate invalid traversal on number: {}",
        err_str
    );
}

// =============================================================================
// TEST 6: Edge Cases
// =============================================================================

/// Test empty prompt - documents behavior without API key
///
/// NOTE: This test is ignored because rig-core panics when API key is not set.
/// When API key IS set, empty prompt is sent to the LLM (may or may not error).
#[tokio::test]
#[ignore = "rig-core panics without API key - run with ANTHROPIC_API_KEY set"]
async fn test_infer_empty_prompt() {
    // This test requires API key to be set (rig-core panics otherwise)
    if std::env::var("ANTHROPIC_API_KEY").is_err() {
        return; // Skip - would panic
    }

    let executor = create_executor();
    let task_id: Arc<str> = "test_empty".into();

    let action = TaskAction::Infer {
        infer: infer_params(""),
    };
    let bindings = ResolvedBindings::new();
    let datastore = RunContext::new();

    // Empty prompt might succeed or fail depending on provider
    // We mainly want to ensure no panic
    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    // Result can be Ok or Err - we just verify no panic
    // If err, it should be a sensible error, not a template error
    if let Err(e) = result {
        let err_str = e.to_string();
        // Should be a provider error (API call), not a template error
        assert!(
            !err_str.contains("panic"),
            "Should not panic on empty prompt"
        );
    }
}

/// Test prompt with extra whitespace in template references
///
/// Verifies that `{{  with.data  }}` (with spaces) is handled correctly.
/// NOTE: This test is ignored because rig-core panics when API key is not set.
#[tokio::test]
#[ignore = "rig-core panics without API key - run with ANTHROPIC_API_KEY set"]
async fn test_infer_whitespace_in_template() {
    // This test requires API key to be set (rig-core panics otherwise)
    if std::env::var("ANTHROPIC_API_KEY").is_err() {
        return; // Skip - would panic
    }

    let executor = create_mock_executor();
    let task_id: Arc<str> = "test_whitespace".into();

    // Template with extra whitespace (should still work)
    let action = TaskAction::Infer {
        infer: infer_params("Value: {{  with.data  }}"),
    };

    let mut bindings = ResolvedBindings::new();
    bindings.set("data", json!("test value"));
    let datastore = RunContext::new();

    // This should fail at API call (if key invalid), not template resolution
    // The whitespace in template should be handled
    let result = executor
        .execute(&task_id, &action, &bindings, &datastore, None)
        .await;

    // If it fails, it should be at API level, not template parsing
    if let Err(e) = result {
        let err_str = e.to_string();
        // Should NOT be a template error (whitespace is valid)
        // Should be provider/API error
        assert!(
            err_str.contains("Provider") || err_str.contains("API") || err_str.contains("401"),
            "Error should be from provider, not template: {}",
            err_str
        );
    }
}

/// Test that whitespace in template syntax is handled correctly
///
/// This is a unit test that doesn't require API calls - it tests
/// the template resolution behavior.
#[test]
fn test_template_whitespace_parsing() {
    // The template regex in binding/template.rs:
    // r"\{\{\s*with\.(\w+(?:\.\w+)*)\s*\}\}"
    //
    // Supports optional whitespace after `{{` and before `}}`.
    // Standard syntax: `{{with.data}}`
    // Also works: `{{with.data }}` (trailing whitespace)

    use nika::binding::template_resolve;

    let mut bindings = ResolvedBindings::new();
    bindings.set("data", json!("resolved_value"));
    let datastore = RunContext::new();

    // Standard syntax - works
    let template = "Value: {{with.data}}";
    let result = template_resolve(template, &bindings, &datastore);
    assert!(result.is_ok(), "Standard template should resolve");
    assert_eq!(result.unwrap().as_ref(), "Value: resolved_value");

    // Trailing whitespace before }} - works
    let template2 = "Value: {{with.data }}";
    let result2 = template_resolve(template2, &bindings, &datastore);
    assert!(
        result2.is_ok(),
        "Template with trailing whitespace should resolve"
    );
    assert_eq!(result2.unwrap().as_ref(), "Value: resolved_value");
}

/// Test templates without whitespace (standard case)
#[test]
fn test_template_no_whitespace() {
    use nika::binding::template_resolve;

    let mut bindings = ResolvedBindings::new();
    bindings.set("value", json!(42));
    let datastore = RunContext::new();

    let template = "Number: {{with.value}}";
    let result = template_resolve(template, &bindings, &datastore);

    assert!(result.is_ok());
    assert_eq!(result.unwrap().as_ref(), "Number: 42");
}

// =============================================================================
// TEST 7: Error Code Verification
// =============================================================================

/// Verify error codes are correctly assigned
#[test]
fn test_error_codes() {
    // Null value error
    let null_err = NikaError::NullValue {
        path: "test.path".to_string(),
        alias: "test".to_string(),
    };
    assert_eq!(null_err.code(), "NIKA-072");

    // Invalid traversal error
    let traversal_err = NikaError::InvalidTraversal {
        segment: "field".to_string(),
        value_type: "string".to_string(),
        full_path: "data.field".to_string(),
    };
    assert_eq!(traversal_err.code(), "NIKA-073");

    // Path not found error
    let path_err = NikaError::PathNotFound {
        path: "data.missing".to_string(),
    };
    assert_eq!(path_err.code(), "NIKA-052");

    // Missing API key error
    let key_err = NikaError::MissingApiKey {
        provider: "claude".to_string(),
    };
    assert_eq!(key_err.code(), "NIKA-032");

    // Provider not configured error
    let provider_err = NikaError::ProviderNotConfigured {
        provider: "openai".to_string(),
    };
    assert_eq!(provider_err.code(), "NIKA-030");
}