1package engine
2
3import (
4	"errors"
5	"fmt"
6	"reflect"
7	"regexp"
8	"strconv"
9
10	"github.com/iancoleman/strcase"
11	"github.com/ooni/probe-engine/model"
12)
13
14// InputPolicy describes the experiment policy with respect to input. That is
15// whether it requires input, optionally accepts input, does not want input.
16type InputPolicy string
17
18const (
19	// InputOrQueryTestLists indicates that the experiment requires
20	// external input to run and that this kind of input is URLs
21	// from the citizenlab/test-lists repository. If this input
22	// not provided to the experiment, then the code that runs the
23	// experiment is supposed to fetch from URLs from OONI's backends.
24	InputOrQueryTestLists = InputPolicy("or_query_test_lists")
25
26	// InputStrictlyRequired indicates that the experiment
27	// requires input and we currently don't have an API for
28	// fetching such input. Therefore, either the user specifies
29	// input or the experiment will fail for the lack of input.
30	InputStrictlyRequired = InputPolicy("strictly_required")
31
32	// InputOptional indicates that the experiment handles input,
33	// if any; otherwise it fetchs input/uses a default.
34	InputOptional = InputPolicy("optional")
35
36	// InputNone indicates that the experiment does not want any
37	// input and ignores the input if provided with it.
38	InputNone = InputPolicy("none")
39)
40
41// ExperimentBuilder is an experiment builder.
42type ExperimentBuilder struct {
43	build         func(interface{}) *Experiment
44	callbacks     model.ExperimentCallbacks
45	config        interface{}
46	inputPolicy   InputPolicy
47	interruptible bool
48}
49
50// Interruptible tells you whether this is an interruptible experiment. This kind
51// of experiments (e.g. ndt7) may be interrupted mid way.
52func (b *ExperimentBuilder) Interruptible() bool {
53	return b.interruptible
54}
55
56// InputPolicy returns the experiment input policy
57func (b *ExperimentBuilder) InputPolicy() InputPolicy {
58	return b.inputPolicy
59}
60
61// OptionInfo contains info about an option
62type OptionInfo struct {
63	Doc  string
64	Type string
65}
66
67// Options returns info about all options
68func (b *ExperimentBuilder) Options() (map[string]OptionInfo, error) {
69	result := make(map[string]OptionInfo)
70	ptrinfo := reflect.ValueOf(b.config)
71	if ptrinfo.Kind() != reflect.Ptr {
72		return nil, errors.New("config is not a pointer")
73	}
74	structinfo := ptrinfo.Elem().Type()
75	if structinfo.Kind() != reflect.Struct {
76		return nil, errors.New("config is not a struct")
77	}
78	for i := 0; i < structinfo.NumField(); i++ {
79		field := structinfo.Field(i)
80		result[field.Name] = OptionInfo{
81			Doc:  field.Tag.Get("ooni"),
82			Type: field.Type.String(),
83		}
84	}
85	return result, nil
86}
87
88// SetOptionBool sets a bool option
89func (b *ExperimentBuilder) SetOptionBool(key string, value bool) error {
90	field, err := fieldbyname(b.config, key)
91	if err != nil {
92		return err
93	}
94	if field.Kind() != reflect.Bool {
95		return errors.New("field is not a bool")
96	}
97	field.SetBool(value)
98	return nil
99}
100
101// SetOptionInt sets an int option
102func (b *ExperimentBuilder) SetOptionInt(key string, value int64) error {
103	field, err := fieldbyname(b.config, key)
104	if err != nil {
105		return err
106	}
107	if field.Kind() != reflect.Int64 {
108		return errors.New("field is not an int64")
109	}
110	field.SetInt(value)
111	return nil
112}
113
114// SetOptionString sets a string option
115func (b *ExperimentBuilder) SetOptionString(key, value string) error {
116	field, err := fieldbyname(b.config, key)
117	if err != nil {
118		return err
119	}
120	if field.Kind() != reflect.String {
121		return errors.New("field is not a string")
122	}
123	field.SetString(value)
124	return nil
125}
126
127var intregexp = regexp.MustCompile("^[0-9]+$")
128
129// SetOptionGuessType sets an option whose type depends on the
130// option value. If the value is `"true"` or `"false"` we
131// assume the option is boolean. If the value is numeric, then we
132// set an integer option. Otherwise we set a string option.
133func (b *ExperimentBuilder) SetOptionGuessType(key, value string) error {
134	if value == "true" || value == "false" {
135		return b.SetOptionBool(key, value == "true")
136	}
137	if !intregexp.MatchString(value) {
138		return b.SetOptionString(key, value)
139	}
140	number, _ := strconv.ParseInt(value, 10, 64)
141	return b.SetOptionInt(key, number)
142}
143
144// SetOptionsGuessType calls the SetOptionGuessType method for every
145// key, value pair contained by the opts input map.
146func (b *ExperimentBuilder) SetOptionsGuessType(opts map[string]string) error {
147	for k, v := range opts {
148		if err := b.SetOptionGuessType(k, v); err != nil {
149			return err
150		}
151	}
152	return nil
153}
154
155// SetCallbacks sets the interactive callbacks
156func (b *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
157	b.callbacks = callbacks
158}
159
160func fieldbyname(v interface{}, key string) (reflect.Value, error) {
161	// See https://stackoverflow.com/a/6396678/4354461
162	ptrinfo := reflect.ValueOf(v)
163	if ptrinfo.Kind() != reflect.Ptr {
164		return reflect.Value{}, errors.New("value is not a pointer")
165	}
166	structinfo := ptrinfo.Elem()
167	if structinfo.Kind() != reflect.Struct {
168		return reflect.Value{}, errors.New("value is not a pointer to struct")
169	}
170	field := structinfo.FieldByName(key)
171	if !field.IsValid() || !field.CanSet() {
172		return reflect.Value{}, errors.New("no such field")
173	}
174	return field, nil
175}
176
177// NewExperiment creates the experiment
178func (b *ExperimentBuilder) NewExperiment() *Experiment {
179	experiment := b.build(b.config)
180	experiment.callbacks = b.callbacks
181	return experiment
182}
183
184// canonicalizeExperimentName allows code to provide experiment names
185// in a more flexible way, where we have aliases.
186func canonicalizeExperimentName(name string) string {
187	switch name = strcase.ToSnake(name); name {
188	case "ndt_7":
189		name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default
190	default:
191	}
192	return name
193}
194
195func newExperimentBuilder(session *Session, name string) (*ExperimentBuilder, error) {
196	factory, _ := experimentsByName[canonicalizeExperimentName(name)]
197	if factory == nil {
198		return nil, fmt.Errorf("no such experiment: %s", name)
199	}
200	builder := factory(session)
201	builder.callbacks = model.NewPrinterCallbacks(session.Logger())
202	return builder, nil
203}
204